Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 64 additions & 6 deletions authbridge/authlib/plugins/ibac/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
//
// Per-request only — no cross-request session-scoped state. Requires
// an a2a-parser in the inbound chain (runtime dependency, fail-closed
// when LastIntent is nil) and works alongside an optional mcp-parser
// (After ordering hint) for richer action descriptions.
// when LastIntent is nil or no session has been seeded yet) and works
// alongside an optional mcp-parser (After ordering hint) for richer
// action descriptions and to bypass MCP protocol housekeeping
// (initialize, *list, notifications/*) that carries no intent.
package ibac

import (
Expand Down Expand Up @@ -245,10 +247,40 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A
return pipeline.Action{Type: pipeline.Continue}
}

// 5. Pull the user's most recent declared intent. nil here means
// either a2a-parser isn't in the inbound chain, or no user
// message has been received yet — both are operator-error /
// suspicious states. Fail closed.
// 5. MCP protocol-housekeeping skip. initialize / notifications /
// *list methods are connection setup and capability discovery —
// they happen before any user request (e.g. agent startup) and
// carry no actionable intent. Same shape as inference_bypass:
// judging them would be a category error, and denying them
// breaks every agent that opens an MCP connection at startup.
// Only methods that invoke side effects (tools/call, prompts/get,
// resources/read) reach the judge.
if pctx.Extensions.MCP != nil && isMCPHousekeeping(pctx.Extensions.MCP.Method) {
pctx.Skip("mcp_housekeeping")
return pipeline.Action{Type: pipeline.Continue}
}

// 6. Pull the user's most recent declared intent. Two distinct
// nil pathways, both fail closed but with different reasons so
// operator dashboards can tell them apart:
// - no_session: no inbound A2A request has populated the
// active session bucket yet (or session tracking is off
// entirely). Common at agent startup before any user turn.
// - no_intent: a session exists but contains no extractable
// user message (a2a-parser missing from inbound chain, or
// events present but none are user-role A2A requests).
if pctx.Session == nil {
action := describeAction(pctx, p.cfg.JudgeInference)
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionDeny,
Phase: pipeline.InvocationPhaseRequest,
Reason: "no_session",
Details: map[string]string{
"action": action,
},
})
return pipeline.DenyStatus(403, "ibac.no_session", "no active session for outbound request")
}
intent := pctx.Session.LastIntent()
intentText := extractIntentText(intent)
if intentText == "" {
Expand Down Expand Up @@ -432,6 +464,32 @@ func formatBodyExcerpt(body []byte, n int) string {
return fmt.Sprintf("%q", string(body))
}

// isMCPHousekeeping reports whether an MCP method is connection-setup
// or capability-discovery traffic that carries no user-actionable
// intent. These methods fire before any user turn (e.g. an agent's
// startup `initialize` against its MCP tool server) and judging them
// would either panic on a nil session or deny on no_intent — both
// break agents that open MCP connections at startup. Only side-effect
// methods (tools/call, prompts/get, resources/read) reach the judge.
//
// JSON-RPC notifications (any method starting with `notifications/`)
// are also bypassed: they're one-way protocol signals, never tied to
// a specific user turn.
func isMCPHousekeeping(method string) bool {
switch method {
case "initialize",
"ping",
"tools/list",
"prompts/list",
"resources/list",
"resources/templates/list",
"completion/complete",
"logging/setLevel":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: logging/setLevel is the only bypassed method that mutates server state (it changes the MCP server's log verbosity). It's low-impact and reasonable to bypass — but the function doc currently frames the allowlist as connection setup / capability discovery / housekeeping, which doesn't quite fit a state-changing call.

Worth a sentence in isMCPHousekeeping's doc explicitly justifying its inclusion (e.g. "transport-level config, not user-actionable intent") so a future reader doesn't pattern-match the list as "read-only methods" and broaden it to other state-changing methods on the same logic.

return true
}
return strings.HasPrefix(method, "notifications/")
}

// extractMCPToolName pulls the tool name from a tools/call request's
// params. Returns "" for non-tools/call methods or missing field.
func extractMCPToolName(mcp *pipeline.MCPExtension) string {
Expand Down
87 changes: 87 additions & 0 deletions authbridge/authlib/plugins/ibac/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,93 @@ func TestOnRequest_NoIntent_FailsClosed(t *testing.T) {
}
}

// Nil Session must not panic. The forward-proxy listener leaves
// pctx.Session nil whenever no inbound A2A request has seeded the
// active session bucket yet — common at agent startup. Treat it as
// a distinct fail-closed reason (no_session) so dashboards can tell
// "no inbound has happened yet" apart from "session exists but
// contains no intent" (no_intent).
func TestOnRequest_NilSession_FailsClosed(t *testing.T) {
fj := &fakeJudge{}
p := newConfiguredIBAC(t, fj)

pctx := makePCtx(t)
pctx.Session = nil // before any inbound A2A
action := invokeOnRequest(p, pctx)

if action.Type != pipeline.Reject {
t.Errorf("got %v, want Reject when Session is nil", action.Type)
}
if fj.calls != 0 {
t.Errorf("judge should not be called when there's no session")
}
inv := lastInvocation(t, pctx)
if inv.Reason != "no_session" {
t.Errorf("Invocation reason = %q, want 'no_session'", inv.Reason)
}
}

// MCP protocol-housekeeping methods carry no user-actionable intent
// (they're connection setup or capability discovery, not side
// effects). IBAC must skip them; otherwise every agent that opens an
// MCP connection at startup gets blocked before any user turn.
func TestOnRequest_MCPHousekeepingBypass(t *testing.T) {
cases := []string{
"initialize",
"ping",
"tools/list",
"prompts/list",
"resources/list",
"resources/templates/list",
"completion/complete",
"logging/setLevel",
"notifications/initialized",
"notifications/cancelled",
}
for _, method := range cases {
t.Run(method, func(t *testing.T) {
fj := &fakeJudge{}
p := newConfiguredIBAC(t, fj)

pctx := makePCtx(t)
pctx.Session = nil // housekeeping must bypass before the session check
pctx.Method = "POST"
pctx.Host = "some-mcp-server"
pctx.Path = "/mcp"
pctx.Extensions.MCP = &pipeline.MCPExtension{Method: method}
action := invokeOnRequest(p, pctx)

if action.Type != pipeline.Continue {
t.Errorf("got %v, want Continue for housekeeping method %q", action.Type, method)
}
if fj.calls != 0 {
t.Errorf("judge should not be called for housekeeping method %q", method)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (echoing CodeRabbit): The test proves the judge wasn't called, but not that the recorded reason is mcp_housekeeping specifically. A typo'd pctx.Skip("mcp_housekeeping") (e.g. mcp_house_keeping) would still pass this test, even though the reason string is part of the new operator-facing contract per the PR description. Adding lastInvocation(t, pctx).Reason == "mcp_housekeeping" would lock that contract in.

}
})
}
}

// MCP tools/call must NOT be treated as housekeeping — it's the
// canonical side-effect method IBAC exists to judge.
func TestOnRequest_MCPToolsCallIsNotHousekeeping(t *testing.T) {
fj := &fakeJudge{verdict: "allow"}
p := newConfiguredIBAC(t, fj)

pctx := makePCtx(t)
pctx.Method = "POST"
pctx.Host = "user-tool"
pctx.Path = "/mcp"
pctx.Extensions.MCP = &pipeline.MCPExtension{
Method: "tools/call",
Params: map[string]any{"name": "delete_user"},
}
_ = invokeOnRequest(p, pctx)

if fj.calls != 1 {
t.Errorf("judge calls = %d, want 1 (tools/call must be judged)", fj.calls)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (echoing CodeRabbit): tools/call is the canonical side-effect, but plugin.go doc also calls out prompts/get and resources/read as judged paths. Parameterizing this test (or adding two siblings) over all three would catch a future maintainer accidentally adding any of them to isMCPHousekeeping. Cheap defense-in-depth — the bug it prevents is hard to spot in review.

}
}

// Email-poison parity: this is the canonical case the plugin exists
// for. Raw HTTP exfiltration to an unknown server, no MCP/Inference
// extensions, judge denies → 403. This test is the contract that
Expand Down
Loading