diff --git a/authbridge/CLAUDE.md b/authbridge/CLAUDE.md index 88ed0f200..ba5376d56 100644 --- a/authbridge/CLAUDE.md +++ b/authbridge/CLAUDE.md @@ -140,6 +140,8 @@ wants to register. - `authlib/pipeline/` -- Plugin interface + lifecycle (`Configurable`, `Initializer`, `Shutdowner`); see [`docs/framework-architecture.md`](docs/framework-architecture.md) - `authlib/plugins/` -- The concrete plugins + registry; see [`docs/plugin-reference.md`](docs/plugin-reference.md) for the per-plugin config convention +**Plugin classification.** Protocol parsers (`mcp-parser`, `a2a-parser`, `inference-parser`) populate an `IsAction bool` field on their respective extensions to classify each request as either a user-meaningful action or protocol mechanics. Default-false means "not classified as action" — guardrails treat it as bypass. Parsers explicitly set `IsAction = true` for the small set of action methods (`tools/call` / `prompts/get` / `resources/read` for MCP; `message/send` / `message/stream` for A2A; every populated case for inference). Guardrails (`ibac` today; future rate limiters, audit loggers, etc.) read the aggregated verdict via `pctx.Classification()` which returns `(anyAction, anyBypass)`. A defense-in-depth guardrail skips on `anyBypass`, passes through on `!anyAction` (no parser claimed this traffic), and judges only when `anyAction && !anyBypass`. This puts the protocol-specific bypass-vs-action vocabulary in each parser — adding a new guardrail or new protocol does not multiply work at the guardrail layer. See [`docs/plugin-reference.md` "Classifying requests"](docs/plugin-reference.md#classifying-requests-as-actions-vs-protocol-mechanics) for the contract. + ### init-iptables.sh Extensively documented shell script that sets up iptables for transparent traffic interception. Key features: diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index 5ccc80cc1..25aa5b710 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -476,6 +476,67 @@ func (c *Context) ContentSources() []contracts.ContentSource { return out } +// Classification reports the request's protocol classification, aggregated +// across every populated protocol extension on Extensions: +// +// - anyAction is true if at least one populated extension has IsAction=true +// (e.g. mcp-parser saw "tools/call"; inference-parser saw any inference call). +// - anyBypass is true if at least one populated extension has IsAction=false +// (e.g. mcp-parser saw "tools/list" or a $transport/* synthetic event). +// +// Both false means no parser populated anything and the request is +// unclassified — guardrails treating IBAC-style defense in depth (only +// fire on traffic a parser claimed) should pass through. +// +// Parser-disjointness assumption. The current in-tree parsers fire on +// disjoint request shapes — mcp-parser on JSON-RPC bodies (or body- +// less MCP-shaped requests on configured paths), a2a-parser on A2A +// JSON-RPC bodies, inference-parser on /v1/{chat/,}completions paths +// — so a single request typically populates at most one extension. +// The aggregation above is defensive (handles the multi-extension +// case if a future hybrid transport ever does double-claim), but +// parser authors should not rely on the aggregation as a feature: a +// parser that populates an extension on a request another parser +// already classified breaks the contract that classification belongs +// to whichever parser owns the wire shape. +// +// Conflict resolution. If anyAction && anyBypass both end up true, +// callers decide their own precedence: +// +// - Defense-in-depth gates (IBAC, rate limiters): treat anyBypass +// as winning — skip first. Safer default when you can't tell who +// to trust. +// - Audit-style guardrails: probably want to log the action even +// if some extension said bypass; flip the precedence. +// +// Either choice is valid; the contract here just provides both signals. +// In practice the question rarely comes up because of the disjointness +// above. +func (c *Context) Classification() (anyAction, anyBypass bool) { + if ext := c.Extensions.MCP; ext != nil { + if ext.IsAction { + anyAction = true + } else { + anyBypass = true + } + } + if ext := c.Extensions.A2A; ext != nil { + if ext.IsAction { + anyAction = true + } else { + anyBypass = true + } + } + if ext := c.Extensions.Inference; ext != nil { + if ext.IsAction { + anyAction = true + } else { + anyBypass = true + } + } + return anyAction, anyBypass +} + // emitBodyMutation records the Invocation and publishes the // plugin-public event carrying length delta + sha256 before/after. // Never logs raw body bytes — the session store is unauthenticated. diff --git a/authbridge/authlib/pipeline/extensions.go b/authbridge/authlib/pipeline/extensions.go index 9ab73bb6c..d3824bf8e 100644 --- a/authbridge/authlib/pipeline/extensions.go +++ b/authbridge/authlib/pipeline/extensions.go @@ -85,12 +85,21 @@ func GetState[T any](pctx *Context, key string) *T { // MCPExtension carries parsed MCP JSON-RPC metadata. // Result and Err are mutually exclusive: a response sets exactly one. +// +// IsAction is the parser's classification verdict (see +// pipeline.Context.Classification). The parser explicitly sets it +// true for user-meaningful action methods (tools/call, prompts/get, +// resources/read); the zero value false means "protocol mechanics +// or unclassified." Default-false reflects defense-in-depth: if the +// parser can't confidently classify a method as an action, guardrails +// err toward letting traffic through rather than judging it. type MCPExtension struct { - Method string `json:"method,omitempty"` - RPCID any `json:"rpcId,omitempty"` - Params map[string]any `json:"params,omitempty"` - Result map[string]any `json:"result,omitempty"` - Err *MCPError `json:"error,omitempty"` + Method string `json:"method,omitempty"` + RPCID any `json:"rpcId,omitempty"` + Params map[string]any `json:"params,omitempty"` + Result map[string]any `json:"result,omitempty"` + Err *MCPError `json:"error,omitempty"` + IsAction bool `json:"isAction,omitempty"` } // MCPError mirrors a JSON-RPC 2.0 error object. @@ -102,6 +111,10 @@ type MCPError struct { // A2AExtension carries parsed A2A protocol metadata from inbound requests // and response summaries for debugging. +// +// IsAction is the parser's classification verdict; see MCPExtension.IsAction +// for the contract. Set true for user-meaningful methods (message/send, +// message/stream); zero value covers protocol/discovery methods. type A2AExtension struct { // Request fields Method string `json:"method,omitempty"` @@ -116,6 +129,9 @@ type A2AExtension struct { FinalStatus string `json:"finalStatus,omitempty"` // "completed", "failed", "canceled" Artifact string `json:"artifact,omitempty"` // final artifact text ErrorMessage string `json:"errorMessage,omitempty"` // failure reason if status is "failed" + + // Classification — see MCPExtension.IsAction. + IsAction bool `json:"isAction,omitempty"` } // A2APart represents a message part in an A2A request. @@ -126,6 +142,10 @@ type A2APart struct { // InferenceExtension carries parsed LLM inference request and response metadata. // Request fields are populated by OnRequest; response fields by OnResponse. +// +// IsAction is the parser's classification verdict; see MCPExtension.IsAction +// for the contract. Inference calls are always actions when populated, so +// inference-parser sets this true unconditionally. type InferenceExtension struct { Model string `json:"model,omitempty"` Messages []InferenceMessage `json:"messages,omitempty"` @@ -143,6 +163,9 @@ type InferenceExtension struct { CompletionTokens int `json:"completionTokens,omitempty"` TotalTokens int `json:"totalTokens,omitempty"` ToolCalls []InferenceToolCall `json:"toolCalls,omitempty"` + + // Classification — see MCPExtension.IsAction. + IsAction bool `json:"isAction,omitempty"` } // InferenceMessage represents a single message in the conversation. diff --git a/authbridge/authlib/pipeline/extensions_test.go b/authbridge/authlib/pipeline/extensions_test.go index 81765341b..6c8576b77 100644 --- a/authbridge/authlib/pipeline/extensions_test.go +++ b/authbridge/authlib/pipeline/extensions_test.go @@ -90,3 +90,60 @@ func TestSetGetState_MultiplePlugins(t *testing.T) { t.Errorf("audit state = %+v", au) } } + +// --- Classification --- + +// No populated extensions → both booleans false. Defense-in-depth +// guardrails (IBAC) treat this as "not our traffic, pass through." +func TestClassification_NoExtensions(t *testing.T) { + pctx := &Context{} + anyAction, anyBypass := pctx.Classification() + if anyAction || anyBypass { + t.Errorf("Classification() = (%v, %v), want (false, false) on empty pctx", + anyAction, anyBypass) + } +} + +// MCP populated with IsAction=true → action; default false → bypass. +func TestClassification_MCPAction(t *testing.T) { + pctx := &Context{} + pctx.Extensions.MCP = &MCPExtension{Method: "tools/call", IsAction: true} + anyAction, anyBypass := pctx.Classification() + if !anyAction || anyBypass { + t.Errorf("Classification() = (%v, %v), want (true, false)", anyAction, anyBypass) + } +} + +func TestClassification_MCPBypass(t *testing.T) { + pctx := &Context{} + pctx.Extensions.MCP = &MCPExtension{Method: "tools/list"} // IsAction=false default + anyAction, anyBypass := pctx.Classification() + if anyAction || !anyBypass { + t.Errorf("Classification() = (%v, %v), want (false, true)", anyAction, anyBypass) + } +} + +// A2A and Inference behave the same way as MCP. +func TestClassification_A2AAndInference(t *testing.T) { + pctx := &Context{} + pctx.Extensions.A2A = &A2AExtension{Method: "message/send", IsAction: true} + pctx.Extensions.Inference = &InferenceExtension{Model: "gpt-4o", IsAction: true} + anyAction, anyBypass := pctx.Classification() + if !anyAction || anyBypass { + t.Errorf("Classification() = (%v, %v), want (true, false) for two action extensions", + anyAction, anyBypass) + } +} + +// Mixed: one extension says action, another says bypass — both flags +// true. Callers decide their precedence (IBAC chooses bypass-wins). +func TestClassification_Mixed(t *testing.T) { + pctx := &Context{} + pctx.Extensions.MCP = &MCPExtension{Method: "tools/call", IsAction: true} + pctx.Extensions.Inference = &InferenceExtension{Model: "gpt-4o"} // IsAction=false + anyAction, anyBypass := pctx.Classification() + if !anyAction || !anyBypass { + t.Errorf("Classification() = (%v, %v), want (true, true) on mixed classification", + anyAction, anyBypass) + } +} diff --git a/authbridge/authlib/plugins/a2aparser/plugin.go b/authbridge/authlib/plugins/a2aparser/plugin.go index 113d481dc..28cf9f5b5 100644 --- a/authbridge/authlib/plugins/a2aparser/plugin.go +++ b/authbridge/authlib/plugins/a2aparser/plugin.go @@ -50,8 +50,9 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin } ext := &pipeline.A2AExtension{ - Method: rpc.Method, - RPCID: rpc.ID, + Method: rpc.Method, + RPCID: rpc.ID, + IsAction: isA2AAction(rpc.Method), } // Extract message fields generically — any method with params.message @@ -355,3 +356,20 @@ func parseA2AParts(rawParts []any) []pipeline.A2APart { } return parts } + +// isA2AAction reports whether an A2A JSON-RPC method name names a +// user-meaningful agent-to-agent call that guardrails should judge. +// Only methods that carry a user message into the agent (or out to +// another agent) qualify; protocol/discovery methods are bypass. +// +// On the inbound side, action methods are how IBAC's session intent +// gets seeded — a2a-parser's classification doesn't drive inbound +// IBAC behavior (IBAC is outbound-only) but the field is set on +// inbound for consistency and for any future inbound guardrail. +func isA2AAction(method string) bool { + switch method { + case "message/send", "message/stream": + return true + } + return false +} diff --git a/authbridge/authlib/plugins/a2aparser/plugin_test.go b/authbridge/authlib/plugins/a2aparser/plugin_test.go index fc6371531..47e6b12d4 100644 --- a/authbridge/authlib/plugins/a2aparser/plugin_test.go +++ b/authbridge/authlib/plugins/a2aparser/plugin_test.go @@ -546,3 +546,37 @@ func TestA2AParser_OnRequest_ContextIDPreferred(t *testing.T) { t.Errorf("SessionID from contextId: got %+v, want ctx-resume", pctx.Extensions.A2A) } } + +// IsAction classification: message/send and message/stream are user- +// meaningful action methods (judge them); everything else is protocol +// mechanics (skip). +func TestA2AParser_Classification(t *testing.T) { + cases := []struct { + method string + body string + isAction bool + }{ + {"message/send", `{"jsonrpc":"2.0","method":"message/send","id":1,"params":{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]}}}`, true}, + {"message/stream", `{"jsonrpc":"2.0","method":"message/stream","id":2,"params":{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]}}}`, true}, + // Hypothetical / future protocol-mechanics methods stay at the + // default false. We can't enumerate every A2A non-action method + // here (some don't exist yet) — the classification is "true for + // known actions, false for everything else" so the table only + // needs known-action coverage plus a representative non-action. + {"agent/discover", `{"jsonrpc":"2.0","method":"agent/discover","id":3}`, false}, + } + for _, tc := range cases { + t.Run(tc.method, func(t *testing.T) { + p := NewA2AParser() + pctx := &pipeline.Context{Body: []byte(tc.body)} + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.A2A == nil { + t.Fatalf("A2A extension nil for method %q", tc.method) + } + if pctx.Extensions.A2A.IsAction != tc.isAction { + t.Errorf("IsAction = %v, want %v for method %q", + pctx.Extensions.A2A.IsAction, tc.isAction, tc.method) + } + }) + } +} diff --git a/authbridge/authlib/plugins/ibac/plugin.go b/authbridge/authlib/plugins/ibac/plugin.go index 8ae6c951f..b93109427 100644 --- a/authbridge/authlib/plugins/ibac/plugin.go +++ b/authbridge/authlib/plugins/ibac/plugin.go @@ -10,12 +10,18 @@ // IBAC catches the outbound exfiltration by comparing the action // against the recorded intent via an LLM judge. // +// IBAC is defense in depth, not a general gatekeeper. It only fires +// on traffic a protocol parser classified — pctx.Classification() +// reports whether any populated extension has IsAction=true. Requests +// nobody classified pass through silently. Per-protocol bypass +// vocabulary (MCP housekeeping methods, transport-layer SSE/session- +// terminate idioms, A2A discovery, etc.) lives in the parsers, not +// here — IBAC just reads the verdict. +// // 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 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. +// at least one of mcp-parser, a2a-parser, or inference-parser in the +// pipeline (RequiresAny) so there's something to drive classification +// — otherwise IBAC would silently no-op on every request. package ibac import ( @@ -105,6 +111,38 @@ type ibacConfig struct { // the right behavior automatically; deployments that want hard // fail-closed semantics opt in via "deny". NoIntentPolicy string `json:"no_intent_policy"` + + // UnclassifiedPolicy controls behavior at step 4 (the + // classification gate) when no protocol parser populated any + // extension on this request — i.e. the request is unclassified. + // Two values: + // + // - "passthrough" (default): record no Skip, return Continue. + // IBAC's defense-in-depth posture — only judge traffic that + // a parser claimed. Plain-HTTP outbound, CORS preflights, + // OAuth metadata fetches, agent-card discovery, and any + // other request shape that the configured parsers don't + // recognize all pass through silently. Pair with egress + // allowlists / NetworkPolicy for plain-HTTP egress control. + // + // - "judge": fall through to the inference policy and intent + // extraction even when no parser claimed the request. Sends + // plain-HTTP outbound (e.g. raw http.Post from local + // function-calling tools) to the judge alongside the + // classified action paths. Wider coverage; comes with the + // standard IBAC operational cost (one extra LLM round-trip + // per outbound request) for traffic that may not benefit + // from intent alignment. Recommended for the IBAC demo and + // for deployments where any outbound request from the agent + // matters and there isn't a complementary egress control. + // + // The default is "passthrough" because production deployments + // using MCP / A2A / inference get full coverage from the + // parser-driven classification, and the cost of judging + // arbitrary HTTP traffic isn't paid for by most operators. + // The IBAC demo opts into "judge" to keep its plain-HTTP exfil + // scenario operational. + UnclassifiedPolicy string `json:"unclassified_policy"` } // no_intent_policy values. @@ -113,6 +151,12 @@ const ( NoIntentPolicyDeny = "deny" ) +// unclassified_policy values. +const ( + UnclassifiedPolicyPassthrough = "passthrough" + UnclassifiedPolicyJudge = "judge" +) + // defaultBypassHosts is the conservative starting set. Operators with // SPIRE / Keycloak / observability stacks deployed under different // service names extend this via config.BypassHosts. @@ -155,6 +199,9 @@ func (c *ibacConfig) applyDefaults() { if c.NoIntentPolicy == "" { c.NoIntentPolicy = NoIntentPolicyAllow } + if c.UnclassifiedPolicy == "" { + c.UnclassifiedPolicy = UnclassifiedPolicyPassthrough + } } func (c *ibacConfig) validate() error { @@ -197,6 +244,13 @@ func (c *ibacConfig) validate() error { return fmt.Errorf("no_intent_policy must be %q or %q, got %q", NoIntentPolicyAllow, NoIntentPolicyDeny, c.NoIntentPolicy) } + switch c.UnclassifiedPolicy { + case UnclassifiedPolicyPassthrough, UnclassifiedPolicyJudge: + // ok + default: + return fmt.Errorf("unclassified_policy must be %q or %q, got %q", + UnclassifiedPolicyPassthrough, UnclassifiedPolicyJudge, c.UnclassifiedPolicy) + } return nil } @@ -222,11 +276,24 @@ func (p *IBAC) Name() string { return "ibac" } func (p *IBAC) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{ - // mcp-parser is optional enrichment; if it's in the chain - // IBAC must come after so it can read the parsed tool name - // and args. If it's absent, IBAC still functions on raw - // HTTP — the judge sees method+host+path+body excerpt. - After: []string{"mcp-parser"}, + // At least one outbound protocol parser must run before IBAC. + // IBAC is a defense-in-depth layer that only fires on traffic + // a parser classified — without a parser, IBAC has no way to + // tell user-meaningful actions from protocol mechanics, and + // would either silently no-op or judge everything (defeats the + // parser-driven design). Boot-fail if no parser is present + // rather than ship a misconfigured pipeline. + // + // a2a-parser is deliberately NOT in this list. RequiresAny is + // a same-chain check, and a2a-parser runs in the INBOUND + // chain in every in-tree config (it seeds Session.LastIntent + // from inbound A2A user turns). IBAC's a2a dependency is the + // inbound session-intent seeding, which the validator can't + // enforce cross-chain anyway — that dependency is runtime, + // governed by no_intent_policy. Listing a2a-parser here would + // only make a misconfigured outbound chain [a2a-parser, ibac] + // pass validation while populating nothing useful for IBAC. + RequiresAny: []string{"mcp-parser", "inference-parser"}, ReadsBody: true, Description: "LLM-judge intent-based access control for outbound tool calls.", } @@ -282,64 +349,64 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A return pipeline.Action{Type: pipeline.Continue} } - // 4. Inference-traffic skip when JudgeInference is false (default). - // Judging the agent's own LLM reasoning is meta-judgment; - // operators can opt in by flipping the config flag. - if pctx.Extensions.Inference != nil && !p.cfg.JudgeInference { - pctx.Skip("inference_bypass") - return pipeline.Action{Type: pipeline.Continue} - } - - // 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} - } - - // 5b. Transport-stream bypass. Two patterns of requests carry no - // user-meaningful action and must skip the judge: - // - // - Body-less GET/HEAD/OPTIONS: retrieval-shaped calls - // (MCP Streamable HTTP server→client SSE channel-open, - // agent-card fetches, OAuth metadata probes, CORS - // preflights, HEAD existence/cache-validation probes). + // 4. Classification gate. Parsers (mcp-parser, a2a-parser, + // inference-parser) are the source of truth for "is this a + // user-meaningful action vs protocol mechanics?" — IBAC just + // reads their verdict via pctx.Classification(). Two outcomes + // short-circuit IBAC here: // - // - Body-less DELETE with the Mcp-Session-Id header: MCP - // Streamable HTTP session termination, sent by the MCP - // client SDK at end-of-conversation to release server- - // side session state. The header is set by the SDK, not - // user input, so it's a precise distinguisher from a - // "DELETE /api/users/42" real-action call. + // - anyBypass: at least one populated extension explicitly + // classified the request as bypass-worthy (e.g. mcp-parser + // saw "tools/list" or a $transport/* synthetic event; + // a2a-parser saw a discovery method). Skip with reason + // "protocol_mechanics". + // - !anyAction: no populated extension classified this as an + // action — i.e. the request is unclassified. Behavior is + // controlled by UnclassifiedPolicy: + // * "passthrough" (default): record no Skip, return + // Continue. Defense-in-depth — IBAC only fires on + // traffic a parser claimed. + // * "judge": fall through to the inference policy and + // intent extraction. Catches plain-HTTP outbound + // (e.g. raw http.Post from local function-calling + // tools) at the cost of one judge round-trip per + // unclassified request. Used by the IBAC demo. // - // Sending either to the judge is a category error: there's - // nothing to judge, and the LLM either denies for lack of - // context or misinterprets the verb (e.g. correctly noting - // "DELETE involves deleting data" without knowing it's - // protocol session cleanup). - // - // Threat model: an attacker can't smuggle a payload through - // a body-less request — there's no body to put it in. Side- - // effect HTTP methods (POST/PUT/DELETE/PATCH) always reach - // the judge when they carry a body. Body-less DELETE without - // the Mcp-Session-Id header — a real "delete this resource" - // call by URL path — also reaches the judge. - // - // Caveat: servers that handle side-effect operations through - // GET query strings (e.g. ?action=delete&id=42) violate REST - // semantics and would bypass IBAC here — by design. Defending - // against that needs to live in the server, not in IBAC; the - // plugin trusts HTTP method semantics as a proxy for "is this - // an action?", and a server that breaks that convention is - // making its own authorization promises that IBAC can't see. - if isTransportShaped(pctx) { - pctx.Skip("transport_stream") + // Action-classified traffic (anyAction=true && !anyBypass) + // always falls through to the inference policy and judge below. + // Mixed classification (anyAction=true && anyBypass=true) is + // rare; the bypass branch wins because the safer default for + // a defense-in-depth control is to defer to the more permissive + // classification. + anyAction, anyBypass := pctx.Classification() + if anyBypass { + pctx.Skip("protocol_mechanics") + return pipeline.Action{Type: pipeline.Continue} + } + if !anyAction { + if p.cfg.UnclassifiedPolicy == UnclassifiedPolicyPassthrough { + // Defense-in-depth pass-through: no parser claimed the + // request, IBAC has no basis to judge it. Don't record a + // Skip — there's no Invocation to pair with, and operators + // infer "ibac is in the pipeline" from config rather than + // from per-event rows. + return pipeline.Action{Type: pipeline.Continue} + } + // UnclassifiedPolicy == "judge" — fall through to the + // inference policy and intent / judge steps below. The IBAC + // demo's plain-HTTP exfiltration scenario relies on this + // branch. + } + + // 5. Inference-traffic skip when JudgeInference is false (default). + // This is operator policy ("don't judge the agent's own LLM + // reasoning by default"), distinct from the parser classification + // above — inference-parser correctly classifies LLM calls as + // actions; this step decides whether to honor that classification + // for inference traffic specifically. Operators flip + // judge_inference: true to opt in to judging. + if pctx.Extensions.Inference != nil && !p.cfg.JudgeInference { + pctx.Skip("inference_bypass") return pipeline.Action{Type: pipeline.Continue} } @@ -585,85 +652,6 @@ func formatBodyExcerpt(body []byte, n int) string { return fmt.Sprintf("%q", string(body)) } -// isMCPHousekeeping reports whether an MCP method is connection-setup, -// capability-discovery, or subscription-management traffic that -// carries no user-actionable intent. These methods fire before, after, -// or alongside user turns as protocol mechanics — judging them would -// either panic on a nil session or deny on no_intent, breaking agents -// that open MCP connections at startup or maintain resource -// subscriptions. Only side-effect methods (tools/call, prompts/get, -// resources/read) reach the judge. -// -// resources/subscribe and resources/unsubscribe are subscription- -// state management — the client tells the server "notify me when -// this resource changes" / "stop notifying me." They go through POST -// with a JSON-RPC body (so the body-less transport_stream bypass -// doesn't apply), but they're conceptually identical to *list calls: -// protocol bookkeeping, not user-meaningful actions. -// -// 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", - "resources/subscribe", - "resources/unsubscribe", - "completion/complete", - "logging/setLevel": - return true - } - return strings.HasPrefix(method, "notifications/") -} - -// isTransportRetrieval reports whether an HTTP method is one of the -// retrieval-shaped methods that, combined with an empty body, signal -// "transport-layer call, not a user-meaningful action." See step 5b -// in OnRequest for the full threat-model rationale. -// -// HEAD shares GET's semantics by RFC 9110 §9.3.2 (servers MUST answer -// HEAD identically to GET, sans body); OPTIONS is CORS preflight or -// capability discovery, never an action. -func isTransportRetrieval(method string) bool { - switch method { - case "GET", "HEAD", "OPTIONS": - return true - } - return false -} - -// isTransportShaped reports whether a request matches a transport- -// layer pattern that carries no user-meaningful action — should be -// skipped before the intent check. Combines two body-less shapes: -// -// - retrieval (GET / HEAD / OPTIONS): see isTransportRetrieval. -// - MCP Streamable HTTP session termination: DELETE with the -// Mcp-Session-Id header. The MCP spec defines this as the way -// a client releases server-side session state at end-of- -// conversation; the header is set by the client SDK, not user -// input, so it's a precise distinguisher from a real -// "DELETE /api/resource" action call. -// -// A request with a non-empty body is always treated as an action, -// regardless of method. -func isTransportShaped(pctx *pipeline.Context) bool { - if len(pctx.Body) > 0 { - return false - } - if isTransportRetrieval(pctx.Method) { - return true - } - if pctx.Method == "DELETE" && pctx.Headers.Get("Mcp-Session-Id") != "" { - return true - } - return false -} - // 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 516b65d2a..b532adc2c 100644 --- a/authbridge/authlib/plugins/ibac/plugin_test.go +++ b/authbridge/authlib/plugins/ibac/plugin_test.go @@ -72,10 +72,25 @@ func invokeOnRequest(p pipeline.Plugin, pctx *pipeline.Context) pipeline.Action // makePCtx builds a minimal outbound pctx with a Session containing a // single A2A user-intent message ("summarize my emails"). Defaults -// model a typical side-effect request — POST with a non-empty body — -// so the pctx reaches the judge unless the test opts into a transport- -// shaped path (body-less GET, housekeeping method, etc). Tests -// override fields by mutating the returned context. +// model a typical side-effect request — POST with a non-empty body +// AND an action-classified MCP extension — so the pctx reaches the +// judge unless the test opts out by clearing the extension or +// flipping IsAction. Tests override fields by mutating the returned +// context. +// +// The MCPExtension default reflects the parser-driven classification +// model: IBAC only judges traffic some parser classified as an +// action. Tests that want to verify pass-through (no classification) +// behavior explicitly set pctx.Extensions.MCP = nil. +// +// CONTRACT CHANGE NOTE. Before the parser-classification refactor, +// makePCtx populated no protocol extension and tests had to opt IN +// by setting pctx.Extensions.MCP/A2A/Inference when they wanted IBAC +// to judge. After the refactor, makePCtx populates an action- +// classified MCPExtension by default and tests opt OUT (set +// Extensions.MCP = nil) when they want to verify pass-through. The +// flip matches IBAC's new defense-in-depth posture: classified +// traffic is the judged path; unclassified is pass-through. func makePCtx(t *testing.T) *pipeline.Context { t.Helper() view := &pipeline.SessionView{ @@ -93,7 +108,7 @@ func makePCtx(t *testing.T) *pipeline.Context { }, }, } - return &pipeline.Context{ + pctx := &pipeline.Context{ Direction: pipeline.Outbound, Method: "POST", Scheme: "http", @@ -103,6 +118,11 @@ func makePCtx(t *testing.T) *pipeline.Context { Body: []byte(`{"city":"sf"}`), Session: view, } + pctx.Extensions.MCP = &pipeline.MCPExtension{ + Method: "tools/call", + IsAction: true, + } + return pctx } // --- Configure --- @@ -281,7 +301,18 @@ func TestOnRequest_InferenceBypassByDefault(t *testing.T) { p := newConfiguredIBAC(t, fj) pctx := makePCtx(t) - pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "gpt-4o-mini"} + // Mirror what inference-parser does in production: every populated + // InferenceExtension is marked as an action. With makePCtx's default + // MCP{IsAction:true} and Inference{IsAction:true}, classification + // returns (anyAction:true, anyBypass:false), so the gate at step 4 + // falls through to the step-5 inference-policy check that this test + // is actually exercising. Without IsAction:true, the test would + // short-circuit at step 4 with skip/protocol_mechanics and pass for + // the wrong reason. + pctx.Extensions.Inference = &pipeline.InferenceExtension{ + Model: "gpt-4o-mini", + IsAction: true, + } action := invokeOnRequest(p, pctx) if action.Type != pipeline.Continue { @@ -290,6 +321,14 @@ func TestOnRequest_InferenceBypassByDefault(t *testing.T) { if fj.calls != 0 { t.Errorf("judge invoked on inference; want 0") } + // Critical assertion: verify step 5 (inference_bypass) is the step + // that fired, not step 4 (protocol_mechanics). A regression in the + // inference-policy logic would otherwise pass silently. + inv := lastInvocation(t, pctx) + if inv.Reason != "inference_bypass" { + t.Errorf("Invocation reason = %q, want %q (step-5 inference policy must be the firing step)", + inv.Reason, "inference_bypass") + } } func TestOnRequest_InferenceJudgedWhenEnabled(t *testing.T) { @@ -302,7 +341,10 @@ func TestOnRequest_InferenceJudgedWhenEnabled(t *testing.T) { p.judge = fj pctx := makePCtx(t) - pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "gpt-4"} + // inference-parser sets IsAction=true unconditionally on populated + // extensions; mirror that here so the classification gate passes + // the request through to the inference-policy step. + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "gpt-4", IsAction: true} invokeOnRequest(p, pctx) if fj.calls != 1 { @@ -419,175 +461,185 @@ func TestOnRequest_NilSession_PolicyAllow_Bypasses(t *testing.T) { } } -// MCP protocol-housekeeping methods carry no user-actionable intent -// (they're connection setup, capability discovery, or subscription -// management — not side effects). IBAC must skip them; otherwise -// every agent that opens an MCP connection at startup gets blocked -// before any user turn, and any agent that maintains resource -// subscriptions sees its bookkeeping calls denied mid-conversation. -func TestOnRequest_MCPHousekeepingBypass(t *testing.T) { - cases := []string{ - "initialize", - "ping", - "tools/list", - "prompts/list", - "resources/list", - "resources/templates/list", - "resources/subscribe", - "resources/unsubscribe", - "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) - } - }) +// Classification gate: when any populated extension reports IsAction= +// false (parser said "this is protocol mechanics"), IBAC skips with +// reason "protocol_mechanics" before the intent check or the judge. +// The actual MCP-method classification logic lives in mcp-parser; see +// plugins/mcpparser/plugin_test.go for the per-method coverage. This +// test exercises IBAC's reading of the verdict only. +func TestOnRequest_ClassificationBypass_ProtocolMechanics(t *testing.T) { + fj := &fakeJudge{} + p := newConfiguredIBAC(t, fj) + + pctx := makePCtx(t) + pctx.Session = nil // bypass must fire before the session check + pctx.Extensions.MCP = &pipeline.MCPExtension{ + Method: "tools/list", // mcp-parser would set IsAction=false (default) + } + action := invokeOnRequest(p, pctx) + + if action.Type != pipeline.Continue { + t.Errorf("got %v, want Continue for protocol_mechanics", action.Type) + } + if fj.calls != 0 { + t.Errorf("judge calls = %d, want 0", fj.calls) + } + inv := lastInvocation(t, pctx) + if inv.Action != pipeline.ActionSkip { + t.Errorf("Invocation action = %v, want ActionSkip", inv.Action) + } + if inv.Reason != "protocol_mechanics" { + t.Errorf("Invocation reason = %q, want 'protocol_mechanics'", inv.Reason) } } -// 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"} +// Defense-in-depth pass-through: when no extension is populated, IBAC +// has nothing to classify and passes through silently — no Skip +// recorded, no judge invocation, just Continue. This is the difference +// between IBAC and a general gate: IBAC is a layer in defense in +// depth, only firing when a parser claims the traffic. +func TestOnRequest_NoClassification_PassesThrough(t *testing.T) { + fj := &fakeJudge{} 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"}, + pctx.Extensions.MCP = nil // remove the default action classification + + action := invokeOnRequest(p, pctx) + + if action.Type != pipeline.Continue { + t.Errorf("got %v, want Continue (defense-in-depth pass-through)", action.Type) } - _ = invokeOnRequest(p, pctx) + if fj.calls != 0 { + t.Errorf("judge calls = %d, want 0 (no classification, pass through)", fj.calls) + } + // Pass-through deliberately records NO Invocation — IBAC has no + // opinion to surface, so abctl would otherwise show a phantom + // "ibac: continue" row on every unrelated request. + if pctx.Extensions.Invocations != nil && + (len(pctx.Extensions.Invocations.Inbound)+len(pctx.Extensions.Invocations.Outbound)) > 0 { + t.Errorf("expected no invocations on pass-through; got %+v", pctx.Extensions.Invocations) + } +} +// Opt-in coverage of unclassified traffic via unclassified_policy: +// "judge". The IBAC demo's plain-HTTP exfiltration scenario relies on +// this branch — without it, raw http.Post outbound from a local +// function-calling tool would pass through silently because no parser +// claims it. +func TestOnRequest_UnclassifiedPolicy_Judge(t *testing.T) { + fj := &fakeJudge{verdict: "deny", reason: "unrelated to user intent"} + p := NewIBAC() + cfg := `{"judge_endpoint":"http://j","judge_model":"m","unclassified_policy":"judge"}` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure: %v", err) + } + p.judge = fj + + pctx := makePCtx(t) + pctx.Extensions.MCP = nil // unclassified — no parser populated anything + pctx.Method = "POST" + pctx.Host = "evil-server.example.com" + pctx.Path = "/collect" + pctx.Body = []byte(`{"exfil":"data"}`) + + action := invokeOnRequest(p, pctx) + + if action.Type != pipeline.Reject { + t.Errorf("got %v, want Reject (unclassified_policy=judge must reach the judge and apply its verdict)", action.Type) + } if fj.calls != 1 { - t.Errorf("judge calls = %d, want 1 (tools/call must be judged)", fj.calls) - } -} - -// Body-less retrieval-shaped requests (MCP Streamable HTTP SSE channel, -// agent-card fetches, OAuth metadata probes, CORS preflights, HEAD -// probes) carry no action payload and must bypass the judge with reason -// "transport_stream". Without this, the MCP client's SSE channel-open -// keeps getting 403'd, the connection dies, and the agent loops on -// reconnect. Covers GET, HEAD, OPTIONS — all share the body-less -// retrieval semantics per RFC 9110. -func TestOnRequest_TransportStreamBypass_BodylessRetrieval(t *testing.T) { - for _, method := range []string{"GET", "HEAD", "OPTIONS"} { - t.Run(method, func(t *testing.T) { - fj := &fakeJudge{} - p := newConfiguredIBAC(t, fj) - - pctx := makePCtx(t) - pctx.Session = nil // bypass must fire before the session check - pctx.Method = method - pctx.Host = "exgentic-mcp-gsm8k-mcp:8000" - pctx.Path = "/mcp" - pctx.Body = nil - action := invokeOnRequest(p, pctx) - - if action.Type != pipeline.Continue { - t.Errorf("got %v, want Continue for body-less %s", action.Type, method) - } - if fj.calls != 0 { - t.Errorf("judge calls = %d, want 0 (body-less %s must not reach judge)", fj.calls, method) - } - inv := lastInvocation(t, pctx) - if inv.Action != pipeline.ActionSkip { - t.Errorf("Invocation action = %v, want ActionSkip", inv.Action) + t.Errorf("judge calls = %d, want 1 (unclassified must reach judge under policy=judge)", fj.calls) + } + inv := lastInvocation(t, pctx) + if inv.Reason != "blocked" { + t.Errorf("Invocation reason = %q, want 'blocked'", inv.Reason) + } +} + +// Configure-time validation of unclassified_policy. +func TestConfigure_UnclassifiedPolicy_DefaultsToPassthrough(t *testing.T) { + p := NewIBAC() + if err := p.Configure(json.RawMessage(`{"judge_endpoint":"http://j","judge_model":"m"}`)); err != nil { + t.Fatalf("Configure: %v", err) + } + if p.cfg.UnclassifiedPolicy != UnclassifiedPolicyPassthrough { + t.Errorf("default UnclassifiedPolicy = %q, want %q", + p.cfg.UnclassifiedPolicy, UnclassifiedPolicyPassthrough) + } +} + +func TestConfigure_UnclassifiedPolicy_RejectsUnknownValue(t *testing.T) { + p := NewIBAC() + cfg := `{"judge_endpoint":"http://j","judge_model":"m","unclassified_policy":"maybe"}` + err := p.Configure(json.RawMessage(cfg)) + if err == nil { + t.Fatal("expected error for unknown unclassified_policy value") + } + if !strings.Contains(err.Error(), "unclassified_policy") { + t.Errorf("error should mention unclassified_policy; got %q", err.Error()) + } +} + +func TestConfigure_UnclassifiedPolicy_AcceptsExplicitValues(t *testing.T) { + for _, v := range []string{"passthrough", "judge"} { + t.Run(v, func(t *testing.T) { + p := NewIBAC() + cfg := fmt.Sprintf(`{"judge_endpoint":"http://j","judge_model":"m","unclassified_policy":%q}`, v) + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure(%q): %v", v, err) } - if inv.Reason != "transport_stream" { - t.Errorf("Invocation reason = %q, want 'transport_stream'", inv.Reason) + if p.cfg.UnclassifiedPolicy != v { + t.Errorf("UnclassifiedPolicy = %q, want %q", p.cfg.UnclassifiedPolicy, v) } }) } } -// Body-having retrieval-shaped requests (rare, but legal — e.g. some -// search APIs accept GET with a JSON body) must still reach the judge: -// the body is the action. -func TestOnRequest_TransportStreamBypass_GETWithBodyIsJudged(t *testing.T) { +// MCP tools/call (an action method per mcp-parser's classification) is +// passed through to the judge. The test mirrors what mcp-parser would +// do: populate MCPExtension with IsAction=true. +func TestOnRequest_MCPActionReachesJudge(t *testing.T) { fj := &fakeJudge{verdict: "allow"} p := newConfiguredIBAC(t, fj) pctx := makePCtx(t) - pctx.Method = "GET" - pctx.Host = "search-api" - pctx.Path = "/q" - pctx.Body = []byte(`{"q":"acme financials"}`) + 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"}, + IsAction: true, + } _ = invokeOnRequest(p, pctx) if fj.calls != 1 { - t.Errorf("judge calls = %d, want 1 (GET with body is an action)", fj.calls) - } -} - -// Body-less POST/PUT/DELETE/PATCH must NOT bypass — the absence of -// body alone isn't enough; the method must signal "retrieval" too. -// A body-less POST is unusual but legal (semantic "do action") and -// IBAC should still judge it. (Body-less DELETE *with* the -// Mcp-Session-Id header is its own special case — see -// TestOnRequest_TransportStream_MCPSessionTerminate.) -func TestOnRequest_TransportStreamBypass_BodylessPOSTIsJudged(t *testing.T) { - for _, method := range []string{"POST", "PUT", "DELETE", "PATCH"} { - t.Run(method, func(t *testing.T) { - fj := &fakeJudge{verdict: "allow"} - p := newConfiguredIBAC(t, fj) - - pctx := makePCtx(t) - pctx.Method = method - pctx.Host = "api.example.com" - pctx.Path = "/refresh" - pctx.Body = nil - _ = invokeOnRequest(p, pctx) - - if fj.calls != 1 { - t.Errorf("judge calls = %d, want 1 for body-less %s", fj.calls, method) - } - }) + t.Errorf("judge calls = %d, want 1 (action-classified MCP must reach judge)", fj.calls) } } -// MCP Streamable HTTP session termination: DELETE to the MCP -// endpoint with the Mcp-Session-Id header. Per spec, MCP clients -// send this at end-of-conversation to release server-side session -// state. It carries no payload and no user-actionable intent — -// must bypass the judge with reason "transport_stream". -// -// Without this bypass, the judge sees "DELETE http://...mcp" with -// an empty body and reasonably (but wrongly) responds along the -// lines of "Action involves deleting data, which is not strictly -// necessary for user intent" — a 403 on routine protocol cleanup -// at end-of-conversation. +// MCP Streamable HTTP session termination: mcp-parser detects the +// body-less DELETE + Mcp-Session-Id pattern and emits a synthetic +// $transport/terminate extension with IsAction=false. IBAC's +// classification gate must read that verdict and skip with reason +// "protocol_mechanics" before the intent check or the judge. +// Per-method parser coverage lives in plugins/mcpparser/plugin_test.go; +// this test exercises the IBAC integration boundary only. func TestOnRequest_TransportStream_MCPSessionTerminate(t *testing.T) { fj := &fakeJudge{} p := newConfiguredIBAC(t, fj) pctx := makePCtx(t) + pctx.Session = nil pctx.Method = "DELETE" pctx.Host = "exgentic-mcp-gsm8k-mcp:8000" pctx.Path = "/mcp" pctx.Body = nil pctx.Headers.Set("Mcp-Session-Id", "abc-123") + pctx.Extensions.MCP = &pipeline.MCPExtension{ + Method: "$transport/terminate", // mcp-parser sets IsAction=false (default) + } action := invokeOnRequest(p, pctx) if action.Type != pipeline.Continue { @@ -600,8 +652,8 @@ func TestOnRequest_TransportStream_MCPSessionTerminate(t *testing.T) { if inv.Action != pipeline.ActionSkip { t.Errorf("Invocation action = %v, want ActionSkip", inv.Action) } - if inv.Reason != "transport_stream" { - t.Errorf("Invocation reason = %q, want 'transport_stream'", inv.Reason) + if inv.Reason != "protocol_mechanics" { + t.Errorf("Invocation reason = %q, want 'protocol_mechanics'", inv.Reason) } } @@ -731,7 +783,8 @@ func TestOnRequest_MCPEnrichment(t *testing.T) { pctx.Host = "user-tool" pctx.Path = "/mcp" pctx.Extensions.MCP = &pipeline.MCPExtension{ - Method: "tools/call", + Method: "tools/call", + IsAction: true, Params: map[string]any{ "name": "delete_user", "arguments": map[string]any{"user_id": "alice"}, diff --git a/authbridge/authlib/plugins/inferenceparser/plugin.go b/authbridge/authlib/plugins/inferenceparser/plugin.go index 5218983d7..cbdea0b78 100644 --- a/authbridge/authlib/plugins/inferenceparser/plugin.go +++ b/authbridge/authlib/plugins/inferenceparser/plugin.go @@ -60,6 +60,12 @@ func (p *InferenceParser) OnRequest(_ context.Context, pctx *pipeline.Context) p TopP: req.TopP, Stream: req.Stream, ToolChoice: req.ToolChoice, + // Every populated InferenceExtension is an outbound LLM call — + // the agent making a real action. The "don't judge inference + // by default" choice is operator policy, lives in IBAC's + // judge_inference config; the classification verdict here is + // independent of that policy. + IsAction: true, } for _, msg := range req.Messages { diff --git a/authbridge/authlib/plugins/inferenceparser/plugin_test.go b/authbridge/authlib/plugins/inferenceparser/plugin_test.go index 7f8eb532f..4723644df 100644 --- a/authbridge/authlib/plugins/inferenceparser/plugin_test.go +++ b/authbridge/authlib/plugins/inferenceparser/plugin_test.go @@ -559,3 +559,22 @@ func TestInferenceParser_NullContent(t *testing.T) { t.Errorf("Content = %q, want empty for null", pctx.Extensions.Inference.Messages[0].Content) } } + +// IsAction is unconditionally true on populated InferenceExtension. +// Every outbound LLM call is, on the wire, an action — the operator +// policy "don't judge inference by default" is encoded separately in +// IBAC's judge_inference config, not in the classification. +func TestInferenceParser_AlwaysClassifiesAsAction(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{ + Path: "/v1/chat/completions", + Body: []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`), + } + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.Inference == nil { + t.Fatal("Inference extension is nil") + } + if !pctx.Extensions.Inference.IsAction { + t.Error("IsAction = false, want true (every populated inference call is an action)") + } +} diff --git a/authbridge/authlib/plugins/mcpparser/plugin.go b/authbridge/authlib/plugins/mcpparser/plugin.go index f0ac2ebea..a423890df 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin.go +++ b/authbridge/authlib/plugins/mcpparser/plugin.go @@ -4,17 +4,78 @@ import ( "bytes" "context" "encoding/json" + "fmt" "log/slog" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/bypass" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/internal/parsercommon" ) +// Synthetic method names emitted on body-less MCP transport-layer +// requests where there's no JSON-RPC method on the wire to report. +// +// The "$" prefix is non-standard — neither MCP nor JSON-RPC 2.0 +// formally reserves it (JSON-RPC 2.0 §6 only reserves "rpc.*"). +// We chose it because no current MCP spec method uses "$" and +// because it's visually distinct from real category/action method +// names; operators reading abctl can tell at a glance that these +// aren't methods that appeared in the request body. If a future MCP +// revision starts using "$" prefixes, switch this scheme to a less +// likely sentinel (e.g. "_transport/stream") at that time. +const ( + syntheticTransportStream = "$transport/stream" + syntheticTransportTerminate = "$transport/terminate" +) + +// mcpConfig is the plugin's local config schema. The MCP-endpoint +// `paths` list scopes body-less transport-layer detection (SSE GET, +// session-terminate DELETE) to known MCP endpoints — without it, +// every body-less GET in the cluster would risk being mis-classified +// as an MCP transport call. +type mcpConfig struct { + // Paths is the set of URL path globs that should be treated as + // MCP endpoints for body-less-request detection. Defaults to + // ["/mcp"] which matches the standard MCP Streamable HTTP setup + // used by the MCP Python SDK and most server templates. + // + // Path-shape detection only fires on body-less requests; body- + // having JSON-RPC requests are parsed regardless of path (the + // JSON-RPC body itself is the protocol signal). + Paths []string `json:"paths"` +} + +func (c *mcpConfig) applyDefaults() { + if len(c.Paths) == 0 { + c.Paths = []string{"/mcp"} + } +} + // MCPParser parses MCP JSON-RPC 2.0 request bodies and populates -// pctx.Extensions.MCP with the method, RPC ID, and raw params for -// downstream policy plugins. -type MCPParser struct{} +// pctx.Extensions.MCP with the method, RPC ID, raw params, and the +// IsAction classification verdict for downstream guardrails. +// +// Recognizes three shapes: +// +// 1. JSON-RPC body (POST /mcp with a valid {jsonrpc, method, ...} +// payload): populates Method/RPCID/Params. IsAction=true for +// known action methods (tools/call, prompts/get, resources/read); +// all other methods leave IsAction at the zero-value false. +// +// 2. Body-less DELETE on a configured path with the Mcp-Session-Id +// header: MCP Streamable HTTP session termination per spec. +// Populates Method=$transport/terminate, IsAction=false. +// +// 3. Body-less GET on a configured path: MCP Streamable HTTP server- +// to-client SSE channel-open. Populates Method=$transport/stream, +// IsAction=false. +// +// Body-less requests on non-configured paths leave Extensions.MCP +// nil — the parser can't reliably tell whether they're MCP traffic. +type MCPParser struct { + paths *bypass.Matcher +} func NewMCPParser() *MCPParser { return &MCPParser{} } @@ -32,12 +93,92 @@ func (p *MCPParser) Capabilities() pipeline.PluginCapabilities { } } +// Configure decodes the optional `paths` list and compiles a path +// matcher used by body-less transport-layer detection. Always +// initializes the matcher (default paths are applied when omitted) +// so OnRequest never has to nil-check. +func (p *MCPParser) Configure(raw json.RawMessage) error { + var c mcpConfig + if len(raw) > 0 { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&c); err != nil { + return fmt.Errorf("mcp-parser config: %w", err) + } + } + c.applyDefaults() + matcher, err := bypass.NewMatcher(c.Paths) + if err != nil { + return fmt.Errorf("mcp-parser paths: %w", err) + } + p.paths = matcher + return nil +} + +// isMCPAction reports whether a JSON-RPC method name names a user- +// meaningful side-effect operation that guardrails should judge. +// The list is small and grows only when MCP introduces a new method +// that carries user intent on the wire. Everything not in this list +// — protocol setup, capability discovery, subscription management, +// notifications, etc. — is treated as protocol mechanics with +// IsAction=false (the zero value). +// +// Aligned with MCP spec revision 2025-03-26 (Streamable HTTP). When +// MCP adds a new action-shaped method (a hypothetical +// "tools/execute_remote", a "prompts/render_with_data", etc.), update +// this list. The audit anchor is the spec-revision string so future +// maintainers have a date to compare against rather than re-deriving +// the action set from scratch. +func isMCPAction(method string) bool { + switch method { + case "tools/call", "prompts/get", "resources/read": + return true + } + return false +} + func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { - // No Invocation recorded when the parser doesn't apply to this - // message — empty body, non-JSON body, or JSON-but-not-JSON-RPC - // (e.g. an OpenAI chat/completions body). Operators infer "mcp- - // parser exists in this pipeline" from config, not per-event rows. + // Body-less transport-layer detection. Scoped to the configured + // MCP-endpoint paths because there's no protocol payload to + // confirm; without the path narrow, every body-less GET in the + // cluster would risk being mis-classified as an MCP SSE channel. if len(pctx.Body) == 0 { + if p.paths != nil && p.paths.Match(pctx.Path) { + switch { + case pctx.Method == "DELETE" && pctx.Headers.Get("Mcp-Session-Id") != "": + // MCP Streamable HTTP session termination per spec — + // the Mcp-Session-Id header is set by the MCP client + // SDK, not user input, so it's a precise distinguisher + // from a real "DELETE /api/users/42" action call. + pctx.Extensions.MCP = &pipeline.MCPExtension{ + Method: syntheticTransportTerminate, + // IsAction defaults to false — protocol mechanics. + } + slog.Info("mcp-parser: session terminate", "path", pctx.Path) + pctx.Observe("matched_" + syntheticTransportTerminate) + return pipeline.Action{Type: pipeline.Continue} + + case pctx.Method == "GET": + // MCP Streamable HTTP server-to-client SSE channel-open. + // Heuristic recognition: any body-less GET on a + // configured MCP path. If the request turns out not to + // be MCP, the worst-case effect is that guardrails + // downstream see a "transport/stream" extension and skip + // it — same effect as the pre-classification behavior + // of letting body-less GETs through. + pctx.Extensions.MCP = &pipeline.MCPExtension{ + Method: syntheticTransportStream, + // IsAction defaults to false — protocol mechanics. + } + slog.Info("mcp-parser: transport stream", "path", pctx.Path) + pctx.Observe("matched_" + syntheticTransportStream) + return pipeline.Action{Type: pipeline.Continue} + } + } + // Empty body, no MCP-shaped transport pattern matched. Don't + // attach an extension — the parser doesn't claim this request. + // Operators infer "mcp-parser exists in this pipeline" from + // config, not per-event rows. slog.Debug("mcp-parser: no body, skipping") return pipeline.Action{Type: pipeline.Continue} } @@ -58,12 +199,13 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin } pctx.Extensions.MCP = &pipeline.MCPExtension{ - Method: rpc.Method, - RPCID: rpc.ID, - Params: rpc.Params, + Method: rpc.Method, + RPCID: rpc.ID, + Params: rpc.Params, + IsAction: isMCPAction(rpc.Method), } - slog.Info("mcp-parser: request", "method", rpc.Method) + slog.Info("mcp-parser: request", "method", rpc.Method, "isAction", pctx.Extensions.MCP.IsAction) slog.Debug("mcp-parser: payload", "method", rpc.Method, "body", parsercommon.Truncate(string(pctx.Body), parsercommon.DebugBodyMax)) pctx.Observe("matched_" + rpc.Method) diff --git a/authbridge/authlib/plugins/mcpparser/plugin_test.go b/authbridge/authlib/plugins/mcpparser/plugin_test.go index 406c9ba84..433c5a78e 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin_test.go +++ b/authbridge/authlib/plugins/mcpparser/plugin_test.go @@ -2,11 +2,34 @@ package mcpparser import ( "context" + "encoding/json" + "net/http" + "strings" "testing" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" ) +// configured returns an MCPParser with paths configured. Most existing +// tests use NewMCPParser() unconfigured (paths matcher is nil), which +// is fine because they only exercise body-having JSON-RPC parsing — +// the path matcher is only consulted on body-less requests. +func configured(t *testing.T, paths ...string) *MCPParser { + t.Helper() + p := NewMCPParser() + cfg := struct { + Paths []string `json:"paths"` + }{Paths: paths} + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal cfg: %v", err) + } + if err := p.Configure(raw); err != nil { + t.Fatalf("Configure: %v", err) + } + return p +} + func TestMCPParser_Capabilities(t *testing.T) { p := NewMCPParser() @@ -394,3 +417,203 @@ func TestMCPParser_OnResponse_SSE_SkipsMalformedFramesUntilGoodOne(t *testing.T) t.Errorf("expected result from second SSE frame, got %v", pctx.Extensions.MCP.Result) } } + +// --- Configure --- + +// Default paths value: omitting the config gives ["/mcp"], which +// matches the standard MCP Streamable HTTP setup. Most operators +// won't override this. +func TestConfigure_DefaultPaths(t *testing.T) { + p := NewMCPParser() + if err := p.Configure(nil); err != nil { + t.Fatalf("Configure(nil): %v", err) + } + // Probe the matcher with a body-less GET on /mcp; if the default + // is right, the synthetic transport extension fires. + pctx := &pipeline.Context{Method: "GET", Path: "/mcp", Headers: http.Header{}} + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP == nil { + t.Fatal("default paths should match /mcp; MCP extension was nil") + } + if pctx.Extensions.MCP.Method != syntheticTransportStream { + t.Errorf("Method = %q, want %q", pctx.Extensions.MCP.Method, syntheticTransportStream) + } +} + +func TestConfigure_RejectsBadPattern(t *testing.T) { + p := NewMCPParser() + err := p.Configure(json.RawMessage(`{"paths":["[bad"]}`)) + if err == nil { + t.Fatal("expected error for invalid path glob") + } + if !strings.Contains(err.Error(), "paths") { + t.Errorf("error should mention paths; got %q", err.Error()) + } +} + +func TestConfigure_RejectsUnknownFields(t *testing.T) { + p := NewMCPParser() + err := p.Configure(json.RawMessage(`{"paths":["/mcp"],"unknown":"x"}`)) + if err == nil { + t.Error("expected error for unknown field") + } +} + +// --- IsAction classification on body-having JSON-RPC requests --- + +// Action methods (tools/call, prompts/get, resources/read) get +// IsAction=true so guardrails know to judge them. Everything else +// stays at the default false (protocol mechanics). +func TestOnRequest_Classification_ActionMethods(t *testing.T) { + cases := []struct { + method string + body string + isAction bool + }{ + // Action methods — judge. + {"tools/call", `{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"x"}}`, true}, + {"prompts/get", `{"jsonrpc":"2.0","method":"prompts/get","id":2,"params":{"name":"x"}}`, true}, + {"resources/read", `{"jsonrpc":"2.0","method":"resources/read","id":3,"params":{"uri":"x"}}`, true}, + // Protocol-mechanics methods — bypass. + {"initialize", `{"jsonrpc":"2.0","method":"initialize","id":4}`, false}, + {"ping", `{"jsonrpc":"2.0","method":"ping","id":5}`, false}, + {"tools/list", `{"jsonrpc":"2.0","method":"tools/list","id":6}`, false}, + {"prompts/list", `{"jsonrpc":"2.0","method":"prompts/list","id":7}`, false}, + {"resources/list", `{"jsonrpc":"2.0","method":"resources/list","id":8}`, false}, + {"resources/templates/list", `{"jsonrpc":"2.0","method":"resources/templates/list","id":9}`, false}, + {"resources/subscribe", `{"jsonrpc":"2.0","method":"resources/subscribe","id":10,"params":{"uri":"x"}}`, false}, + {"resources/unsubscribe", `{"jsonrpc":"2.0","method":"resources/unsubscribe","id":11,"params":{"uri":"x"}}`, false}, + {"completion/complete", `{"jsonrpc":"2.0","method":"completion/complete","id":12}`, false}, + {"logging/setLevel", `{"jsonrpc":"2.0","method":"logging/setLevel","id":13}`, false}, + {"notifications/initialized", `{"jsonrpc":"2.0","method":"notifications/initialized"}`, false}, + {"notifications/cancelled", `{"jsonrpc":"2.0","method":"notifications/cancelled"}`, false}, + } + for _, tc := range cases { + t.Run(tc.method, func(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{Body: []byte(tc.body)} + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP == nil { + t.Fatalf("MCP extension nil for method %q", tc.method) + } + if pctx.Extensions.MCP.IsAction != tc.isAction { + t.Errorf("IsAction = %v, want %v for method %q", + pctx.Extensions.MCP.IsAction, tc.isAction, tc.method) + } + }) + } +} + +// --- Body-less transport-layer detection --- + +// MCP Streamable HTTP session termination: DELETE on the configured +// path with the Mcp-Session-Id header. The header is set by the MCP +// client SDK and is the precise distinguisher from a real "delete +// resource" call. +func TestOnRequest_TransportTerminate(t *testing.T) { + p := configured(t, "/mcp") + pctx := &pipeline.Context{ + Method: "DELETE", + Path: "/mcp", + Headers: http.Header{"Mcp-Session-Id": []string{"abc-123"}}, + } + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP == nil { + t.Fatal("expected synthetic MCP extension") + } + if pctx.Extensions.MCP.Method != syntheticTransportTerminate { + t.Errorf("Method = %q, want %q", pctx.Extensions.MCP.Method, syntheticTransportTerminate) + } + if pctx.Extensions.MCP.IsAction { + t.Error("IsAction should be false for transport terminate") + } +} + +// MCP Streamable HTTP SSE channel-open: body-less GET on the +// configured path. +func TestOnRequest_TransportStream(t *testing.T) { + p := configured(t, "/mcp") + pctx := &pipeline.Context{ + Method: "GET", + Path: "/mcp", + Headers: http.Header{}, + } + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP == nil { + t.Fatal("expected synthetic MCP extension") + } + if pctx.Extensions.MCP.Method != syntheticTransportStream { + t.Errorf("Method = %q, want %q", pctx.Extensions.MCP.Method, syntheticTransportStream) + } + if pctx.Extensions.MCP.IsAction { + t.Error("IsAction should be false for transport stream") + } +} + +// Body-less DELETE on configured path WITHOUT the Mcp-Session-Id +// header doesn't look like MCP transport — could be a real resource +// delete (DELETE /mcp/something) on an API that happens to share +// the path. Don't claim it. +func TestOnRequest_BodylessDELETEWithoutSessionHeader_NoExtension(t *testing.T) { + p := configured(t, "/mcp") + pctx := &pipeline.Context{ + Method: "DELETE", + Path: "/mcp", + Headers: http.Header{}, + } + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP != nil { + t.Errorf("expected no MCP extension, got %+v", pctx.Extensions.MCP) + } +} + +// Body-less request on a non-configured path: parser doesn't claim +// it. Defense in depth — without the path narrow, we'd be guessing +// that any body-less GET is MCP-shaped, which is wrong for non-MCP +// agent traffic. +func TestOnRequest_BodylessOnUnconfiguredPath_NoExtension(t *testing.T) { + p := configured(t, "/mcp") // only /mcp is configured + pctx := &pipeline.Context{ + Method: "GET", + Path: "/some/other/api", + Headers: http.Header{}, + } + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP != nil { + t.Errorf("expected no MCP extension on unconfigured path, got %+v", pctx.Extensions.MCP) + } +} + +// Body-less POST on configured path: not a recognized MCP transport +// shape (MCP uses POST only with bodies). Don't claim it. +func TestOnRequest_BodylessPOST_NoExtension(t *testing.T) { + p := configured(t, "/mcp") + pctx := &pipeline.Context{ + Method: "POST", + Path: "/mcp", + Headers: http.Header{}, + } + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP != nil { + t.Errorf("expected no MCP extension for body-less POST, got %+v", pctx.Extensions.MCP) + } +} + +// Custom paths config: operators can scope MCP detection to their +// actual MCP endpoint paths. +func TestConfigure_CustomPaths(t *testing.T) { + p := configured(t, "/api/v1/mcp", "/legacy-mcp") + for _, path := range []string{"/api/v1/mcp", "/legacy-mcp"} { + pctx := &pipeline.Context{Method: "GET", Path: path, Headers: http.Header{}} + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP == nil { + t.Errorf("custom path %q should match", path) + } + } + // /mcp is NOT in the custom list. + pctx := &pipeline.Context{Method: "GET", Path: "/mcp", Headers: http.Header{}} + _ = p.OnRequest(context.Background(), pctx) + if pctx.Extensions.MCP != nil { + t.Error("/mcp should NOT match when custom paths exclude it") + } +} diff --git a/authbridge/demos/ibac/Makefile b/authbridge/demos/ibac/Makefile index d6793575c..04ff4bd9d 100644 --- a/authbridge/demos/ibac/Makefile +++ b/authbridge/demos/ibac/Makefile @@ -113,13 +113,19 @@ $(STAMP_DIR)/evil-server.built: $(shell find evil-server -type f) go.mod | $(STA build-images: $(STAMP_DIR)/agent.built $(STAMP_DIR)/email-server.built $(STAMP_DIR)/evil-server.built ## Build demo images (rebuilds only when sources change) -build-abctl: ## Build abctl into /tmp (cached, used by show-result) - @if [ -x /tmp/abctl-ibac-demo ]; then \ - echo "[*] /tmp/abctl-ibac-demo already built; skipping. \`rm /tmp/abctl-ibac-demo\` to force rebuild."; \ +build-abctl: ## Build abctl into /tmp (rebuilds when source is newer; used by show-result) + @ABCTL_SRC_DIR=../../cmd/abctl; \ + if [ -x /tmp/abctl-ibac-demo ]; then \ + NEWER=$$(find $$ABCTL_SRC_DIR -name '*.go' -newer /tmp/abctl-ibac-demo -print -quit 2>/dev/null); \ + if [ -z "$$NEWER" ]; then \ + echo "[*] /tmp/abctl-ibac-demo is up to date with the abctl source; skipping. \`rm /tmp/abctl-ibac-demo\` to force rebuild."; \ + exit 0; \ + fi; \ + echo "[*] abctl source has been modified since /tmp/abctl-ibac-demo was built — rebuilding ..."; \ else \ echo "[*] Building abctl into /tmp/abctl-ibac-demo ..."; \ - cd ../../cmd/abctl && go build -o /tmp/abctl-ibac-demo . ; \ - fi + fi; \ + cd $$ABCTL_SRC_DIR && go build -o /tmp/abctl-ibac-demo . # load-images reloads each image when EITHER: # - the kind node doesn't have it under docker.io/library/, OR diff --git a/authbridge/demos/ibac/agent/main.go b/authbridge/demos/ibac/agent/main.go index 4362af3c6..e6cc81449 100644 --- a/authbridge/demos/ibac/agent/main.go +++ b/authbridge/demos/ibac/agent/main.go @@ -710,6 +710,22 @@ func handleA2A(w http.ResponseWriter, r *http.Request) { // session header. Keep this for parity with the legacy endpoint. sessionID = r.Header.Get("X-Session-Id") } + if sessionID == "" { + // A2A spec §6.6: when the client omits a contextId, the server + // SHOULD assign one and return it. The kagenti UI currently + // sends bare message/send calls without any contextId field; + // without this mint, every conversation collapses into the + // "default" session bucket on the authbridge side (the rekey- + // on-response path needs a contextId on the response to + // migrate Default → ). Each test run / chat turn + // then becomes indistinguishable in abctl, which defeats the + // IBAC demo's per-conversation forensic view. Returning a + // fresh UUID restores per-conversation bucketing while staying + // backward-compatible: a UI that ever does start round- + // tripping contextIds gets the original sessionID through the + // outer branches. + sessionID = newUUID() + } log.Printf("[Agent] A2A query (session=%s): %s", sessionID, query) result, err := runAgent(query, sessionID) diff --git a/authbridge/demos/ibac/k8s/ibac-patch.yaml b/authbridge/demos/ibac/k8s/ibac-patch.yaml index 8e6ef7b05..8f6aac9c1 100644 --- a/authbridge/demos/ibac/k8s/ibac-patch.yaml +++ b/authbridge/demos/ibac/k8s/ibac-patch.yaml @@ -55,6 +55,20 @@ outbound_append: judge_inference: false agent_llm_host: "host.docker.internal" + # The IBAC demo's exfiltration scenario emits a plain HTTP + # POST from the agent's local function-calling tool to + # evil-server — it doesn't carry an MCP / A2A / inference + # protocol envelope, so no parser claims it. The default + # IBAC behavior under that condition is "passthrough" + # (defense-in-depth — only judge what a parser claimed). + # Set unclassified_policy: "judge" so plain-HTTP outbound + # also reaches the judge and the demo's threat-model + # walkthrough works end-to-end. Production deployments using + # MCP/A2A/inference exclusively should leave this at the + # default "passthrough" and rely on egress allowlists for + # plain-HTTP outbound control. + unclassified_policy: "judge" + # Optional: override the judge's system prompt. When unset (the # default in this demo), the plugin uses defaultSystemPrompt # from authlib/plugins/ibac/judge.go — a generic diff --git a/authbridge/docs/framework-architecture.md b/authbridge/docs/framework-architecture.md index 7f52bbad8..5f85aed9f 100644 --- a/authbridge/docs/framework-architecture.md +++ b/authbridge/docs/framework-architecture.md @@ -219,6 +219,8 @@ Adding a named slot is an authlib-core change: edit `Extensions`, add a wire fie **Capability interfaces on the slot types.** Named-slot extensions may implement optional capability interfaces declared in [`authlib/contracts/`](../authlib/contracts/) so consumer plugins can interact with them without importing any specific parser package. The current capability is [`ContentSource`](../authlib/contracts/content.go) — implemented by `A2AExtension`, `MCPExtension`, and `InferenceExtension` — which lets guardrail plugins iterate inspectable text fragments via `pctx.ContentSources()`. Parser authors opt in by adding one method (`Fragments() []contracts.Fragment`); consumers see a uniform view across every protocol that implements the contract. See [`plugin-reference.md` "Exposing content to guardrails"](./plugin-reference.md#exposing-content-to-guardrails) for the pattern. +**Classification on the slot types.** Each protocol extension also carries an `IsAction bool` field — the parser's verdict on whether the request is a user-meaningful action (judge it) or protocol mechanics (skip it). Parsers explicitly set `IsAction = true` for the small set of known action methods (`tools/call`, `prompts/get`, `resources/read` for MCP; `message/send`, `message/stream` for A2A; every populated case for inference); everything else inherits the zero-value false. Guardrails read the verdict aggregated across every populated extension via [`pctx.Classification()`](../authlib/pipeline/context.go), which returns `(anyAction, anyBypass)`. A defense-in-depth guardrail (IBAC pattern) skips on `anyBypass`, passes through on `!anyAction`, and judges only when `anyAction && !anyBypass`. This keeps protocol-specific knowledge in the parsers — adding a new guardrail or a new protocol doesn't multiply work at the guardrail layer. + ### `Custom map[string]any` — plugin-private state + escape-hatch public events Two access patterns share the same map, disambiguated by key suffix. @@ -260,24 +262,26 @@ All at `authbridge/authlib/pipeline/extensions.go`: ```go type MCPExtension struct { - Method string // JSON-RPC method, e.g. "tools/call" - RPCID any // JSON-RPC id (could be int or string) - Params map[string]any // request params - Result map[string]any // response result (mutually exclusive with Err) - Err *MCPError + Method string // JSON-RPC method, e.g. "tools/call"; or "$transport/stream" / "$transport/terminate" for body-less MCP transport patterns + RPCID any // JSON-RPC id (could be int or string) + Params map[string]any // request params + Result map[string]any // response result (mutually exclusive with Err) + Err *MCPError + IsAction bool // parser's classification verdict; true for tools/call, prompts/get, resources/read; false for housekeeping + transport } type A2AExtension struct { - Method string - RPCID any - SessionID string // contextId from the client, or server-assigned on first turn - MessageID string - TaskID string - Role string // "user" | "agent" - Parts []A2APart - FinalStatus string // response: "completed" | "failed" | "canceled" - Artifact string // response: assembled artifact text - ErrorMessage string // response: failure reason + Method string + RPCID any + SessionID string // contextId from the client, or server-assigned on first turn + MessageID string + TaskID string + Role string // "user" | "agent" + Parts []A2APart + FinalStatus string // response: "completed" | "failed" | "canceled" + Artifact string // response: assembled artifact text + ErrorMessage string // response: failure reason + IsAction bool // parser's classification verdict; true for message/send + message/stream } type InferenceExtension struct { @@ -297,6 +301,8 @@ type InferenceExtension struct { CompletionTokens int TotalTokens int ToolCalls []InferenceToolCall + // Classification: every populated InferenceExtension is an outbound LLM call. + IsAction bool // unconditionally true when populated } type SecurityExtension struct { diff --git a/authbridge/docs/ibac-plugin.md b/authbridge/docs/ibac-plugin.md index 7c801b53d..1a05d4575 100644 --- a/authbridge/docs/ibac-plugin.md +++ b/authbridge/docs/ibac-plugin.md @@ -107,25 +107,46 @@ they're ordered cheapest-first: 3. **Host bypass.** If the request host matches one of `bypass_hosts`, record `skip/host_bypass` and `Continue`. Defaults cover Keycloak, SPIRE, OTel, Jaeger, Prometheus. -4. **Inference bypass.** If `pctx.Extensions.Inference` is populated and - `judge_inference` is `false` (default), record `skip/inference_bypass` - and `Continue`. Judging the agent's own LLM-reasoning calls is - high-cost, low-value for typical deployments. -5. **Intent extraction.** Read `pctx.Session.LastIntent()`. If empty - (operator forgot to put `a2a-parser` in the inbound chain, or the - session has received no user message), record `deny/no_intent` and - return `DenyStatus(403, "ibac.no_intent", ...)` — **fail closed**. -6. **Build action description.** Always include the bare HTTP request +4. **Classification gate.** Read `pctx.Classification()`, which aggregates + the `IsAction` verdict across every populated protocol extension + (`pctx.Extensions.{MCP, A2A, Inference}`): + - **`anyBypass=true`** (some parser explicitly classified the request + as protocol mechanics — MCP `tools/list`, A2A discovery, MCP + `$transport/stream` etc): record `skip/protocol_mechanics` and + `Continue`. + - **`!anyAction`** (no parser populated anything; IBAC has no opinion): + `Continue` silently with no recorded Skip — defense-in-depth + pass-through. + - **`anyAction=true && !anyBypass`** (action-classified): fall through + to the next step. + + The protocol-specific bypass vocabulary (housekeeping methods, transport + shapes, etc.) lives in the parsers. Add support for a new protocol or a + new method to its parser; IBAC reads the classification verdict + uniformly without protocol-specific code. +5. **Inference operator-policy bypass.** If `pctx.Extensions.Inference` is + populated and `judge_inference` is `false` (default), record + `skip/inference_bypass` and `Continue`. Distinct from the classification + gate above — inference-parser correctly classifies LLM calls as actions; + this step is operator policy ("don't judge the agent's own reasoning by + default"). Operators flip `judge_inference: true` to opt in. +6. **Intent extraction.** Read `pctx.Session.LastIntent()`. If empty, + apply `no_intent_policy` (default `"allow"`): record + `skip/no_user_context` and `Continue` (treats the request as a + legitimate self-action). Operators wanting strict fail-closed semantics + set `no_intent_policy: "deny"` to get the previous `deny/no_intent` + 403 behavior. +7. **Build action description.** Always include the bare HTTP request line + body excerpt. If `mcp-parser` populated `Extensions.MCP`, append the tool name and args. If `inference-parser` populated `Extensions.Inference` and `judge_inference` is on, append the model name and first user message. **Authorization and Cookie headers are never included** — the judge LLM should never see bearer tokens or session cookies. -7. **Call the judge.** Send a chat-completion request to the configured +8. **Call the judge.** Send a chat-completion request to the configured endpoint. Caller-context deadlines apply on top of the per-call `timeout_ms`. Two error buckets (see Status Codes below). -8. **Apply the verdict.** `allow` → record `allow/aligned` and +9. **Apply the verdict.** `allow` → record `allow/aligned` and `Continue`. `deny` → record `deny/blocked` and return `DenyStatus(403, "ibac.blocked", reason)`. @@ -164,35 +185,40 @@ pipeline: | `agent_llm_host` | No | `""` | Convenience: host of the agent's own LLM endpoint. Added to the bypass-host list so reasoning traffic is skipped regardless of `judge_inference`. | | `bypass_hosts` | No | Built-in list | Host globs (`path.Match` syntax) skipped without judging. Defaults: `keycloak.*`, `keycloak`, `spire-server.*`, `spire-agent.*`, `otel-collector.*`, `jaeger.*`, `prometheus.*`. **Bare `*` and similarly-broad patterns are rejected at startup.** | | `bypass_paths` | No | Built-in list | URL path globs skipped without judging. Defaults: `/.well-known/*`, `/healthz`, `/readyz`, `/livez`. | +| `no_intent_policy` | No | `"allow"` | Behavior when an action-classified request has no recorded user intent. `"allow"` skips with `no_user_context`; `"deny"` rejects with `403 ibac.no_intent` / `ibac.no_session`. See "Default security posture" in Limitations. | +| `unclassified_policy` | No | `"passthrough"` | Behavior at the classification gate when no parser populated any extension. `"passthrough"` returns Continue silently (defense in depth); `"judge"` falls through to the judge for plain-HTTP outbound coverage. The IBAC demo uses `"judge"`. See "Default security posture" in Limitations. | ### Pipeline Composition -IBAC has a runtime dependency on `a2a-parser` (inbound) and an optional -soft-ordering dependency on `mcp-parser` (outbound): +IBAC declares `RequiresAny: ["mcp-parser", "inference-parser"]` +in its `Capabilities()` — the pipeline validator boot-fails if none of +those parsers is in the same outbound chain. Without a parser, IBAC has +no classification to read and would silently no-op on every request, so +the strict requirement catches misconfig at boot rather than letting it +ship a broken pipeline. ```yaml pipeline: inbound: plugins: - - name: a2a-parser # REQUIRED — populates Session.Intents + - name: a2a-parser # populates Session.Intents for IBAC's intent extraction - name: jwt-validation outbound: plugins: - name: token-exchange - - name: mcp-parser # OPTIONAL — enriches IBAC's view of the action + - name: mcp-parser # at least one parser must precede ibac - name: ibac ``` -The `mcp-parser` ordering is enforced via the plugin's -`Capabilities.After: ["mcp-parser"]` hint, so the pipeline validator -will reject configurations where `ibac` precedes `mcp-parser` in the same -chain. +In practice every IBAC deployment includes `mcp-parser` (and usually +`inference-parser`); the `RequiresAny` is the safety net for accidental +"ibac-only" pipelines that would otherwise judge nothing. -The `a2a-parser` dependency is **runtime, not chain-time** — the pipeline -validator can't see across chains, so a missing `a2a-parser` in the -inbound chain produces a `nil` intent at runtime and IBAC fails closed -with `ibac.no_intent`. Operators see `403 ibac.no_intent` in agent logs -when the inbound chain is misconfigured. +The `a2a-parser` dependency for **intent extraction** is runtime, not +chain-time — the pipeline validator can't see across chains, so a missing +`a2a-parser` in the **inbound** chain leaves `Session.LastIntent()` empty +at runtime. Behavior in that state is governed by `no_intent_policy` +(default `"allow"` — pass through; `"deny"` — fail-closed 403). ## Status Codes & Reasons @@ -303,10 +329,48 @@ SentinelHeaderName: "X-IBAC-Judge"})` — see - **No retry / circuit breaker.** Plugin authors retrying transient judge failures, or breaking a circuit on a flapping judge, layer that on the calling site or in the LLM-judge service itself. -- **Plain-HTTP exfiltration is the primary target.** MCP-shaped - exfiltration is judged too (and gets richer enrichment via - `mcp-parser`), but the original threat shape is raw HTTP from local - function-calling tools. + +### Default security posture + +IBAC ships with two fail-open defaults that operators may want to +override depending on threat model. Both are config knobs on the +`ibac` plugin block. + +- **`unclassified_policy: "passthrough"`** (default). Traffic that no + protocol parser claimed — plain-HTTP outbound from local function- + calling tools, agent-card discovery, OAuth metadata fetches, CORS + preflights — passes through silently. IBAC's defense-in-depth + posture: only judge traffic a parser identified as user-meaningful. + Set `"judge"` for deployments where any outbound request from the + agent matters; the IBAC demo uses this setting to keep its plain- + HTTP exfiltration scenario operational. Production deployments + using MCP/A2A/inference exclusively should leave this at the + default and rely on egress allowlists / NetworkPolicy for plain- + HTTP outbound control. + +- **`no_intent_policy: "allow"`** (default). Action-classified + traffic that arrives before an inbound A2A turn has seeded a user + intent — agent startup, machine-to-machine flows, headless cron- + driven agents — passes through with a `skip/no_user_context` + Invocation. Set `"deny"` for deployments where every action is + required to be user-driven; missing intent then fails closed with + `403 ibac.no_intent` and `403 ibac.no_session` respectively. + +Both defaults reflect IBAC's posture: judge what the parsers tell us +is a real action against a real user intent; let everything else +through and rely on complementary controls (egress policy, +NetworkPolicy, JWT validation) for the cases IBAC isn't suited to. + +### Threat-coverage notes + +- **Plain-HTTP exfiltration** is covered when `unclassified_policy: + "judge"` is set (and is the primary scenario the IBAC demo + exercises). Under the default `passthrough`, plain-HTTP outbound + passes through silently. +- **MCP-shaped exfiltration** (the agent's tool-calling LLM emits a + `tools/call` that doesn't align with user intent) is covered by + default — `mcp-parser` populates `MCPExtension{IsAction:true}` and + the request flows to the judge regardless of `unclassified_policy`. ## Failure Modes (Detailed) diff --git a/authbridge/docs/plugin-reference.md b/authbridge/docs/plugin-reference.md index be9c4f1ed..f430c9b84 100644 --- a/authbridge/docs/plugin-reference.md +++ b/authbridge/docs/plugin-reference.md @@ -957,6 +957,75 @@ Binary protocols, control-plane RPCs (MCP `initialize` / `ping`, tools/list), and identity-only auth messages have no inspectable text — simply don't implement the interface. +## Classifying requests as actions vs protocol mechanics + +A second protocol-agnostic contract sits alongside `ContentSource`: +parser plugins set `IsAction bool` on their extensions to tell guardrails +"is this a user-meaningful action or just protocol mechanics?" +Guardrails read the aggregated verdict via +[`pctx.Classification()`](../authlib/pipeline/context.go) without +importing any specific parser package. + +This is the mechanism that lets a defense-in-depth guardrail (IBAC, a +rate limiter, an audit logger) handle multi-protocol traffic uniformly: +each parser owns its own protocol's bypass-vs-action vocabulary; the +guardrail asks one question and gets one answer. + +### The contract + +```go +// On every protocol extension: +type MCPExtension struct { + // ... existing fields ... + IsAction bool // set true for user-meaningful action methods +} +``` + +Default-false: zero-value (uninitialized) means "not classified as an +action," which guardrails treat as bypass. Parsers explicitly set +`IsAction = true` for the small set of methods that carry user intent +on the wire. This matches IBAC's defense-in-depth posture: when in +doubt, err toward letting traffic through, not toward judging it. + +### Classification per in-tree parser + +| Parser | `IsAction = true` for | Notes | +|---|---|---| +| `mcp-parser` | `tools/call`, `prompts/get`, `resources/read` | All other JSON-RPC methods (housekeeping, notifications, list ops, subscribe/unsubscribe) inherit default false. Synthetic transport extensions (`$transport/stream`, `$transport/terminate`) also stay at default false. | +| `a2a-parser` | `message/send`, `message/stream` | Discovery / protocol setup methods inherit default false. | +| `inference-parser` | every populated case | An LLM call is always an action on the wire; "don't judge inference by default" lives as IBAC operator policy, not in the classification. | + +### Consuming the classification in a guardrail + +```go +func (p *RateLimiter) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { + isAction, isBypass := pctx.Classification() + if isBypass { + // Some parser said "this is protocol mechanics" — don't count it. + return pipeline.Action{Type: pipeline.Continue} + } + if !isAction { + // No parser claimed this request — defense in depth, pass through. + return pipeline.Action{Type: pipeline.Continue} + } + // Action — count it against the rate limit. + return p.limit(pctx) +} +``` + +The shape matches IBAC's gate: skip on bypass, pass-through when +unclassified, act on action. + +### When NOT to set IsAction=true + +Most parser methods are not actions. The bar for marking a method +`IsAction = true` is "guardrails would correctly want to judge or +rate-limit or audit this on a per-call basis." Discovery, capability +listing, subscription management, notifications, transport +housekeeping — none of those qualify. Adding a method to the action +set is a security-relevant change because it pulls the method into +every guardrail's evaluation surface; review accordingly. + ## Registering a plugin A plugin advertises itself to the pipeline builder through `RegisterPlugin`