diff --git a/authbridge/authlib/plugins/ibac/plugin.go b/authbridge/authlib/plugins/ibac/plugin.go index 4fb032622..653850555 100644 --- a/authbridge/authlib/plugins/ibac/plugin.go +++ b/authbridge/authlib/plugins/ibac/plugin.go @@ -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 ( @@ -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 == "" { @@ -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": + 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 { diff --git a/authbridge/authlib/plugins/ibac/plugin_test.go b/authbridge/authlib/plugins/ibac/plugin_test.go index 7f6a74d87..f537a1d2f 100644 --- a/authbridge/authlib/plugins/ibac/plugin_test.go +++ b/authbridge/authlib/plugins/ibac/plugin_test.go @@ -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) + } + }) + } +} + +// 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) + } +} + // 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