From efa65c31e64e9fc5232a8e08c8b6effcddc12986 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sat, 30 May 2026 09:19:07 -0400 Subject: [PATCH 1/9] feat(pipeline): Add IsAction classification on protocol extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an IsAction bool field to MCPExtension, A2AExtension, and InferenceExtension. Add Context.Classification() that aggregates the field across all populated extensions and returns (anyAction, anyBypass). Both false means no parser populated anything — i.e. the request is unclassified. This is the wire for a forthcoming refactor that pushes protocol- specific classification logic out of guardrail plugins (IBAC today) and into the parsers that own each protocol. After this commit, no behavior changes — every parser still leaves IsAction at the zero value, every guardrail still uses its existing bypass logic. Subsequent commits move the per-protocol classification down into mcp-parser / a2a-parser / inference-parser, then flip IBAC to read the aggregated verdict via pctx.Classification(). Default-false IsAction reflects defense-in-depth: when the parser can't confidently classify a method as a user-meaningful action, guardrails err toward letting traffic through rather than judging it. Parsers MUST explicitly set IsAction=true on the small set of known action methods (e.g. tools/call, message/send); everything else inherits the zero value and is treated as protocol mechanics or unclassified. The two-boolean return distinguishes "explicitly classified as bypass" (anyBypass=true) from "no parser claimed this request" (both false). Callers can act on the distinction: - IBAC pattern: anyBypass → Skip+Continue; !anyAction → Continue (defense-in-depth pass-through); else fall through to judge. - A general gate would treat both states as "skip me, no judgment available." Tests: Classification on empty pctx, single-extension action, single-extension bypass, multi-extension agreement, multi-extension mixed (both flags true). All in pipeline/extensions_test.go. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/pipeline/context.go | 43 ++++++++++++++ authbridge/authlib/pipeline/extensions.go | 33 +++++++++-- .../authlib/pipeline/extensions_test.go | 57 +++++++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index 5ccc80cc1..eed3f2a73 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -476,6 +476,49 @@ 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. +// +// The two booleans are independent because in principle a single request +// could populate multiple extensions; today this is rare, but if e.g. a +// future hybrid transport carried both an MCP envelope and an inference +// payload, the request might be classified as both action (judge it) and +// bypass (skip it). Callers decide their own precedence — IBAC, for +// instance, treats anyBypass as winning over anyAction (skip first). +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) + } +} From 638eaac2f42b868d364d836241ada4e47b875c45 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sat, 30 May 2026 09:23:20 -0400 Subject: [PATCH 2/9] feat(mcp-parser): Classify methods + recognize transport via path config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new behaviors, all owned by mcp-parser so guardrails don't have to know about them: 1. Body-having JSON-RPC requests now carry a classification verdict in MCPExtension.IsAction. The action methods are tools/call, prompts/get, resources/read — small list, easy to keep auditable. Everything else (initialize, *list, subscribe, unsubscribe, notifications/*, completion/complete, logging/setLevel) inherits the zero value false, which guardrails treat as protocol mechanics to skip. 2. Body-less DELETE on a configured MCP path with the Mcp-Session-Id header is recognized as MCP Streamable HTTP session termination. The parser populates a synthetic MCPExtension{Method: "$transport/terminate", IsAction: false}. The Mcp-Session-Id header is set by the MCP client SDK (never user input), so it's the precise distinguisher from a real "DELETE /api/users/42" action call — body-less DELETE without the header doesn't get claimed. 3. Body-less GET on a configured MCP path is recognized as MCP Streamable HTTP server-to-client SSE channel-open. Synthetic MCPExtension{Method: "$transport/stream", IsAction: false}. The "$" prefix on the synthetic methods is reserved — real MCP methods follow a category/action naming convention with no "$" — so operators reading abctl can tell at a glance these aren't methods that appeared on the wire. New mcp-parser config: # mcp-parser plugin config paths: - "/mcp" # default; matches MCP Streamable HTTP setups Path-shape detection is scoped to the configured paths list. Without this scope, a body-less GET to any host would risk being mis-classified as MCP transport. Default ["/mcp"] covers the standard MCP Python SDK setup; operators with non-default endpoint paths add explicit entries. This commit changes mcp-parser's behavior but does NOT change any guardrail's behavior — IBAC still uses its existing inline checks on this branch. The IBAC migration to read Classification() lands in a later commit; this commit is the prerequisite that gives the classification something to read. Tests: - Default paths config matches /mcp. - Configure rejects bad path globs and unknown fields. - Per-method classification table: 14 methods (3 action + 11 non- action) including all the housekeeping methods previously listed in IBAC's isMCPHousekeeping. - Body-less DELETE+Mcp-Session-Id on configured path → synthetic $transport/terminate. - Body-less GET on configured path → synthetic $transport/stream. - Body-less DELETE without Mcp-Session-Id on configured path → no extension (could be a real resource-delete call). - Body-less request on non-configured path → no extension. - Body-less POST on configured path → no extension (not a recognized MCP transport shape). - Custom paths config scopes detection to operator-specified endpoints. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../authlib/plugins/mcpparser/plugin.go | 152 +++++++++++- .../authlib/plugins/mcpparser/plugin_test.go | 223 ++++++++++++++++++ 2 files changed, 364 insertions(+), 11 deletions(-) diff --git a/authbridge/authlib/plugins/mcpparser/plugin.go b/authbridge/authlib/plugins/mcpparser/plugin.go index f0ac2ebea..a3bc82dcb 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin.go +++ b/authbridge/authlib/plugins/mcpparser/plugin.go @@ -4,17 +4,73 @@ 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 reserved (real MCP methods follow a category/ +// action naming convention with no "$"), so operators reading abctl +// can tell at a glance these aren't methods that appeared in the +// request body. +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 +88,85 @@ 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). +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 +187,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") + } +} From d75beee37bd5144759ff0a874ed40031b19d625e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sat, 30 May 2026 09:25:04 -0400 Subject: [PATCH 3/9] feat(a2a-parser): Classify outbound A2A methods Set A2AExtension.IsAction = true for the user-meaningful agent-to- agent methods message/send and message/stream; everything else (agent-card discovery, future protocol/discovery methods) inherits the zero-value false. On the inbound side a2a-parser's classification doesn't drive guardrail behavior today (IBAC is outbound-only) but the field is set on inbound for consistency and for any future inbound guardrail. Tests assert IsAction is set correctly for both action methods plus a representative non-action method. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../authlib/plugins/a2aparser/plugin.go | 22 ++++++++++-- .../authlib/plugins/a2aparser/plugin_test.go | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) 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) + } + }) + } +} From abdf6c7e4eec2c3244918c4f28516bfb387bba69 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sat, 30 May 2026 09:26:16 -0400 Subject: [PATCH 4/9] feat(inference-parser): Mark all populated requests as actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every populated InferenceExtension represents an outbound LLM call, which is on the wire an action — the agent is reaching out to its LLM. Set IsAction=true unconditionally when populating the extension. The "don't judge inference by default" semantics live in IBAC's judge_inference operator-config flag, not in the classification. This commit keeps classification (parser concern) cleanly separate from policy (guardrail concern): an audit logger could legitimately record inference traffic even when IBAC bypasses it, because the underlying request was an action regardless of how IBAC chooses to treat it. Test asserts the always-true behavior on a representative chat- completions request. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../authlib/plugins/inferenceparser/plugin.go | 6 ++++++ .../plugins/inferenceparser/plugin_test.go | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) 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)") + } +} From 18ec26f15ea6d7be5b99ad607fd66fd15e126d84 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sat, 30 May 2026 09:36:26 -0400 Subject: [PATCH 5/9] refactor(ibac): Replace inline protocol logic with classification gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous IBAC encoded MCP-protocol knowledge inline: isMCPHousekeeping (the housekeeping-method list), isTransportRetrieval (GET/HEAD/OPTIONS detection for body-less requests), and the body-less DELETE+Mcp-Session-Id check. As more guardrails (rate limiter, audit logger, etc.) and more protocols (A2A subroutes, OTEL, gRPC) land, each guardrail would have to re-derive the same protocol mechanics. Replace all three protocol-specific bypass steps with a single classification gate driven by pctx.Classification(). The parsers (mcp-parser, a2a-parser, inference-parser) own each protocol's bypass-vs-action vocabulary; IBAC just reads the aggregated verdict. Behavior changes: - Step 5 (mcp_housekeeping bypass), step 5b (transport_stream bypass): both deleted. mcp-parser now classifies known methods AND the body-less MCP transport patterns (SSE channel-open via GET, session-terminate via DELETE+Mcp-Session-Id), populating MCPExtension.IsAction or a synthetic $transport/* extension as appropriate. - New step 4 (classification gate) reads (anyAction, anyBypass) from pctx.Classification(). anyBypass → Skip("protocol_mechanics"); !anyAction → Continue silently (defense-in-depth pass-through); action-classified traffic falls through to the existing inference operator-policy step and judge. - Step 5 in the new ordering (was 4) is the inference_bypass operator policy ("don't judge inference by default"). Stays in IBAC because it's policy, not classification — inference-parser correctly classifies LLM calls as actions; this step decides whether to honor that classification for inference traffic. Capabilities change: - Capabilities now declares RequiresAny: ["mcp-parser", "a2a-parser", "inference-parser"] in place of After: ["mcp-parser"]. Without a parser there's no classification, and IBAC would silently no-op on every request — boot-fail catches this misconfig rather than letting it ship. Test cleanup: - Removed TestOnRequest_MCPHousekeepingBypass — moved to mcp-parser. - Removed TestOnRequest_TransportStreamBypass_* — body-less GET/ HEAD/OPTIONS recognition moved to mcp-parser as the synthetic $transport/stream extension on configured paths. - Renamed TestOnRequest_MCPToolsCallIsNotHousekeeping to TestOnRequest_MCPActionReachesJudge — same intent, new framing. - Added TestOnRequest_ClassificationBypass_ProtocolMechanics asserting the single gate skips on anyBypass=true. - Added TestOnRequest_NoClassification_PassesThrough asserting the defense-in-depth pass-through with no recorded Skip. - Updated makePCtx default to populate MCPExtension{IsAction: true} so existing tests reach the judge as they expect. Documentation: - authbridge/CLAUDE.md gains a "Plugin classification" paragraph under the plugins overview pointing to the per-parser contract. - docs/framework-architecture.md gets a "Classification on the slot types" paragraph alongside the existing ContentSource discussion; the extension struct definitions now show IsAction. - docs/plugin-reference.md gets a "Classifying requests as actions vs protocol mechanics" section parallel to "Exposing content to guardrails", with the per-parser table and a guardrail-side consumption example. - docs/ibac-plugin.md flow rewritten to reflect the new step ordering: reentrancy → bypass_paths → bypass_hosts → classification → inference_bypass → intent → judge → verdict. Pipeline-composition section updated to mention RequiresAny. Net effect at the guardrail layer: ~50 lines of IBAC-specific protocol/transport knowledge replaced by ~10 lines of classification- reading. The mechanism scales linearly with parser count, not with request-shape count. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/CLAUDE.md | 2 + authbridge/authlib/plugins/ibac/plugin.go | 204 +++++---------- .../authlib/plugins/ibac/plugin_test.go | 247 +++++++----------- authbridge/docs/framework-architecture.md | 36 +-- authbridge/docs/ibac-plugin.md | 72 +++-- authbridge/docs/plugin-reference.md | 69 +++++ 6 files changed, 301 insertions(+), 329 deletions(-) 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/plugins/ibac/plugin.go b/authbridge/authlib/plugins/ibac/plugin.go index 8ae6c951f..29f94c8f3 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 ( @@ -222,11 +228,14 @@ 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 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 (judging nothing) or judge everything (defeats + // the parser-driven design). Boot-fail if no parser is present + // rather than ship a misconfigured pipeline. + RequiresAny: []string{"mcp-parser", "a2a-parser", "inference-parser"}, ReadsBody: true, Description: "LLM-judge intent-based access control for outbound tool calls.", } @@ -282,64 +291,54 @@ 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") + // 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: + // + // - 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. Could be unclassified traffic (no parser + // populated anything) or fully-bypassed traffic. IBAC is + // defense in depth — when it has no opinion, it passes + // through. The Skip reason is recorded so operators can + // tell apart "we passed through because nothing claimed + // this" from "we passed through because everything said + // bypass" via the anyBypass case above. + // + // Action-classified traffic (anyAction=true && !anyBypass) + // 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} } - - // 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") + if !anyAction { + // 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} } - // 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). - // - // - 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. - // - // 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") + // 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 +584,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..5bfd6ea9b 100644 --- a/authbridge/authlib/plugins/ibac/plugin_test.go +++ b/authbridge/authlib/plugins/ibac/plugin_test.go @@ -72,10 +72,16 @@ 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. func makePCtx(t *testing.T) *pipeline.Context { t.Helper() view := &pipeline.SessionView{ @@ -93,7 +99,7 @@ func makePCtx(t *testing.T) *pipeline.Context { }, }, } - return &pipeline.Context{ + pctx := &pipeline.Context{ Direction: pipeline.Outbound, Method: "POST", Scheme: "http", @@ -103,6 +109,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 --- @@ -302,7 +313,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 +433,111 @@ 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"}, - } - _ = invokeOnRequest(p, pctx) + pctx.Extensions.MCP = nil // remove the default action classification - 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) - } - if inv.Reason != "transport_stream" { - t.Errorf("Invocation reason = %q, want 'transport_stream'", inv.Reason) - } - }) + action := invokeOnRequest(p, pctx) + + if action.Type != pipeline.Continue { + t.Errorf("got %v, want Continue (defense-in-depth pass-through)", action.Type) + } + 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) } } -// 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 +550,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 +681,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/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..73fed20e1 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)`. @@ -167,32 +188,35 @@ pipeline: ### 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", "a2a-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 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` From ced7bd02bef7e89491314dce9ee3c1a786a6f21b Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sat, 30 May 2026 09:53:42 -0400 Subject: [PATCH 6/9] docs(plugins): Address review feedback on PR #452 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review-feedback follow-ups, all documentation only — no behavior changes. 1. mcpparser/plugin.go isMCPAction: add an MCP spec-revision anchor (2025-03-26 Streamable HTTP) so future maintainers adding a new action method have a date stamp to compare against rather than re-deriving the action set. 2. mcpparser/plugin.go synthetic methods: soften the "$ prefix is reserved" framing. JSON-RPC 2.0 §6 only reserves "rpc.*"; "$" is our own design choice motivated by visual distinguish- ability, not by spec reservation. Note the fallback escape hatch (switch to "_transport/*") if MCP ever starts using "$". 3. pipeline/context.go Classification doc: surface the parser- disjointness assumption that makes the multi-extension-mixed case rare, and explicitly note that callers (defense-in-depth gates vs audit-style guardrails) may want different precedence between anyAction and anyBypass when both are true. 4. ibac/plugin_test.go makePCtx: add a CONTRACT CHANGE NOTE documenting the opt-in → opt-out flip introduced by the parser-classification refactor (was: tests opt in by setting Extensions.MCP; now: tests opt out by setting it nil) so a future test author doesn't get confused about why their "no extension" test case is judging. Pure comment churn; tests and behavior unchanged. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/pipeline/context.go | 30 +++++++++++++++---- .../authlib/plugins/ibac/plugin_test.go | 9 ++++++ .../authlib/plugins/mcpparser/plugin.go | 20 ++++++++++--- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/authbridge/authlib/pipeline/context.go b/authbridge/authlib/pipeline/context.go index eed3f2a73..25aa5b710 100644 --- a/authbridge/authlib/pipeline/context.go +++ b/authbridge/authlib/pipeline/context.go @@ -488,12 +488,30 @@ func (c *Context) ContentSources() []contracts.ContentSource { // unclassified — guardrails treating IBAC-style defense in depth (only // fire on traffic a parser claimed) should pass through. // -// The two booleans are independent because in principle a single request -// could populate multiple extensions; today this is rare, but if e.g. a -// future hybrid transport carried both an MCP envelope and an inference -// payload, the request might be classified as both action (judge it) and -// bypass (skip it). Callers decide their own precedence — IBAC, for -// instance, treats anyBypass as winning over anyAction (skip first). +// 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 { diff --git a/authbridge/authlib/plugins/ibac/plugin_test.go b/authbridge/authlib/plugins/ibac/plugin_test.go index 5bfd6ea9b..175019e0e 100644 --- a/authbridge/authlib/plugins/ibac/plugin_test.go +++ b/authbridge/authlib/plugins/ibac/plugin_test.go @@ -82,6 +82,15 @@ func invokeOnRequest(p pipeline.Plugin, pctx *pipeline.Context) pipeline.Action // 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{ diff --git a/authbridge/authlib/plugins/mcpparser/plugin.go b/authbridge/authlib/plugins/mcpparser/plugin.go index a3bc82dcb..a423890df 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin.go +++ b/authbridge/authlib/plugins/mcpparser/plugin.go @@ -15,10 +15,15 @@ import ( // 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 reserved (real MCP methods follow a category/ -// action naming convention with no "$"), so operators reading abctl -// can tell at a glance these aren't methods that appeared in the -// request body. +// +// 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" @@ -117,6 +122,13 @@ func (p *MCPParser) Configure(raw json.RawMessage) error { // — 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": From f0015077107e002f391f0a250cde4dff924b901e Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 31 May 2026 08:04:02 -0400 Subject: [PATCH 7/9] =?UTF-8?q?fix(ibac):=20Address=20PR=20#452=20review?= =?UTF-8?q?=20=E2=80=94=20must-fixes=20+=20RequiresAny=20+=20posture=20doc?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review-feedback follow-ups in one commit, scoped to IBAC plus its demo manifest and reference docs. Build + test pass. 1. Must-fix #1 — TestOnRequest_InferenceBypassByDefault assertion was passing for the wrong reason. With makePCtx populating MCPExtension{IsAction:true} by default, the test's InferenceExtension{IsAction:false} (zero value) caused step 4 (classification gate) to skip with protocol_mechanics before step 5 (inference-policy) could run. A regression in the inference-policy logic would have gone uncaught. Fix: set IsAction:true on the Inference extension (matches what inference-parser does in production). Add an explicit assertion on inv.Reason == "inference_bypass" so the test verifies the right step fired. Same fix shape as the earlier 944838d correction to TestOnRequest_InferenceJudgedWhenEnabled, which missed this sibling. 2. Must-fix #2 — IBAC demo's plain-HTTP exfiltration scenario (the headline ibac-plugin.md threat model) silently passed through the new defense-in-depth gate, since no parser claims plain HTTP and step 4 short-circuits with !anyAction. Fix: introduce unclassified_policy as a config field on the ibac plugin, parallel in shape to the existing no_intent_policy. - Default "passthrough": current behavior. Defense in depth — IBAC only judges what a parser classified. - "judge": falls through to inference policy + intent + judge even when no parser claimed the request. Catches plain-HTTP outbound at the cost of one judge round-trip per unclassified request. Demo's k8s/ibac-patch.yaml sets unclassified_policy: "judge" so the headline scenario keeps working. Production deployments using MCP/A2A/inference exclusively get cleaner defense-in-depth behavior without changing config; deployments that want plain- HTTP egress coverage opt in. 3. Inline suggestion — RequiresAny: ["mcp-parser", "a2a-parser", "inference-parser"] was misleading. RequiresAny is a same-chain check; IBAC runs OUTBOUND, but a2a-parser runs INBOUND in every in-tree config. Including it would let an outbound chain [a2a-parser, ibac] pass validation while a2a-parser populates nothing IBAC reads. Fix: drop a2a-parser. RequiresAny: ["mcp-parser", "inference-parser"]. Doc-comment in Capabilities() explains the cross-chain a2a dependency stays runtime, governed by no_intent_policy. 4. Suggestion — operator-facing default-posture documentation. docs/ibac-plugin.md gains a "Default security posture" subsection under Limitations covering both fail-open knobs (unclassified_policy, no_intent_policy) and how operators choose between them. Config-table rows added for both. Threat-coverage notes added: plain-HTTP exfil is covered when unclassified_policy: "judge"; MCP-shaped exfil is covered by default. The PR's compatibility section is intentionally NOT amended for no_intent_policy — that flag landed in #450 (already in main), not #452, and the durable home for the operator guidance is the plugin's reference doc. Tests: - TestOnRequest_InferenceBypassByDefault now asserts inv.Reason. - New TestOnRequest_UnclassifiedPolicy_Judge covers the policy=judge branch (unclassified body-having POST → reaches judge → reject). - New TestConfigure_UnclassifiedPolicy_DefaultsToPassthrough. - New TestConfigure_UnclassifiedPolicy_RejectsUnknownValue. - New TestConfigure_UnclassifiedPolicy_AcceptsExplicitValues (parameterized over {passthrough, judge}). - Existing tests pass unchanged — default behavior preserved. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/plugins/ibac/plugin.go | 114 ++++++++++++++---- .../authlib/plugins/ibac/plugin_test.go | 95 ++++++++++++++- authbridge/demos/ibac/k8s/ibac-patch.yaml | 14 +++ authbridge/docs/ibac-plugin.md | 50 +++++++- 4 files changed, 244 insertions(+), 29 deletions(-) diff --git a/authbridge/authlib/plugins/ibac/plugin.go b/authbridge/authlib/plugins/ibac/plugin.go index 29f94c8f3..b93109427 100644 --- a/authbridge/authlib/plugins/ibac/plugin.go +++ b/authbridge/authlib/plugins/ibac/plugin.go @@ -111,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. @@ -119,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. @@ -161,6 +199,9 @@ func (c *ibacConfig) applyDefaults() { if c.NoIntentPolicy == "" { c.NoIntentPolicy = NoIntentPolicyAllow } + if c.UnclassifiedPolicy == "" { + c.UnclassifiedPolicy = UnclassifiedPolicyPassthrough + } } func (c *ibacConfig) validate() error { @@ -203,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 } @@ -228,14 +276,24 @@ func (p *IBAC) Name() string { return "ibac" } func (p *IBAC) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{ - // At least one 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 (judging nothing) or judge everything (defeats - // the parser-driven design). Boot-fail if no parser is present + // 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. - RequiresAny: []string{"mcp-parser", "a2a-parser", "inference-parser"}, + // + // 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.", } @@ -303,19 +361,22 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A // a2a-parser saw a discovery method). Skip with reason // "protocol_mechanics". // - !anyAction: no populated extension classified this as an - // action. Could be unclassified traffic (no parser - // populated anything) or fully-bypassed traffic. IBAC is - // defense in depth — when it has no opinion, it passes - // through. The Skip reason is recorded so operators can - // tell apart "we passed through because nothing claimed - // this" from "we passed through because everything said - // bypass" via the anyBypass case above. + // 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. // // Action-classified traffic (anyAction=true && !anyBypass) - // 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 + // 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 { @@ -323,11 +384,18 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A return pipeline.Action{Type: pipeline.Continue} } if !anyAction { - // 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} + 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). diff --git a/authbridge/authlib/plugins/ibac/plugin_test.go b/authbridge/authlib/plugins/ibac/plugin_test.go index 175019e0e..b532adc2c 100644 --- a/authbridge/authlib/plugins/ibac/plugin_test.go +++ b/authbridge/authlib/plugins/ibac/plugin_test.go @@ -301,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 { @@ -310,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) { @@ -503,6 +522,80 @@ func TestOnRequest_NoClassification_PassesThrough(t *testing.T) { } } +// 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 (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 p.cfg.UnclassifiedPolicy != v { + t.Errorf("UnclassifiedPolicy = %q, want %q", p.cfg.UnclassifiedPolicy, v) + } + }) + } +} + // 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. 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/ibac-plugin.md b/authbridge/docs/ibac-plugin.md index 73fed20e1..1a05d4575 100644 --- a/authbridge/docs/ibac-plugin.md +++ b/authbridge/docs/ibac-plugin.md @@ -185,10 +185,12 @@ 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 declares `RequiresAny: ["mcp-parser", "a2a-parser", "inference-parser"]` +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 @@ -327,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) From f1e28b02ea266489a87ddae858b4cb8a2dfa9d68 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 31 May 2026 08:29:19 -0400 Subject: [PATCH 8/9] fix(demos/ibac): Rebuild abctl when source changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `make build-abctl` target was a no-invalidation cache: if /tmp/abctl-ibac-demo existed, it was reused, even if the abctl source had changed since. Anyone iterating on abctl while testing the IBAC demo would silently get a stale binary out of `make show-result` and hit cryptic runtime errors when the binary's behavior diverged from the current source (e.g. picker port- forward features added after the binary was built). Concrete symptom that surfaced this: after the abctl picker port- forward was added to the source, an old cached binary still connected to a hardcoded localhost:9094, while the picker was allocating random local ports — abctl reported "connection refused" against a port nothing was listening on. Replace the if-exists check with a freshness check: rebuild the binary when any .go file under cmd/abctl/ is newer than the cached binary. Equivalent to how the demo's per-image .built stamp files invalidate against source mtimes elsewhere in the same Makefile. Behavior: - First run: builds. - Subsequent runs with no abctl-source changes: skips with a message naming the cache file. - Subsequent runs after editing any .go under cmd/abctl/: rebuilds, prints "source has been modified since ... rebuilding". The launch script (scripts/launch-abctl.sh) already matches abctl's picker model — it just runs the binary and the picker handles port-forward setup. The bug was purely the stale-binary cache; no script changes needed. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/demos/ibac/Makefile | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 From eec27ad563d02b4b6192d13596fbf660a01abfce Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Sun, 31 May 2026 08:38:52 -0400 Subject: [PATCH 9/9] fix(demos/ibac): Mint a contextId when the client omits one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2A spec §6.6 says the server SHOULD assign a contextId on first interaction when the client omits one. The IBAC demo's email-agent treated `sessionID` as a passthrough trace identifier — read it from the inbound message, fall back to X-Session-Id header, return whatever was found (including the empty string) — and never minted. The kagenti UI currently sends bare `message/send` calls with no contextId / sessionId / taskId field, so the agent's sessionID was empty on every turn. After commit 84c323d (drop ActiveSession fallback) the listener stopped silently inheriting prior buckets on empty contextId, so every conversation now collapses into the "default" session bucket. Operators testing the demo can't distinguish two test runs in abctl — both turns share one bucket even after a UI refresh. Fix: when both the message-level contextId and the X-Session-Id header are empty, mint a fresh UUID and use that as sessionID. The listener's rekey-on-response path then migrates Default → on the agent's reply, giving each conversation its own bucket. Backward-compatible: - UI sends contextId → agent uses it (unchanged) - UI sends X-Session-Id header → agent uses it (unchanged) - UI sends nothing → agent mints, returns it; listener rekeys If the kagenti UI ever starts round-tripping contextIds (which it should, per spec), continuity across turns within a conversation is preserved automatically — the same UUID stays in use, the same bucket stays. `newUUID()` is the existing UUID helper used elsewhere in this file (writeRPCSuccess uses it for taskID). Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/demos/ibac/agent/main.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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)