refactor(plugins): Push protocol classification down to parsers#452
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughParsers add an ChangesRequest classification contract and integration
Demos and tooling
Sequence Diagram(s)sequenceDiagram
participant Request
participant IBAC
participant Classification
participant Judge
participant Skip
Request->>IBAC: OnRequest
IBAC->>Classification: pctx.Classification()
alt anyBypass == true
Classification->>Skip: protocol_mechanics skip
else anyAction == false
Classification->>IBAC: pass through (no skip)
else anyAction == true
IBAC->>Judge: proceed to judge/evaluate
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
authbridge/docs/ibac-plugin.md (1)
29-36:⚠️ Potential issue | 🟠 Major | ⚡ Quick winThreat-model walkthrough now contradicts the new classification gate.
This section (and the Limitations note at Lines 330-333) states that the plain-HTTP
POST evil-server/collect— explicitly "plain HTTP, not MCP, not inference traffic" — is judged and denied with403 ibac.blockedwhen IBAC is enabled. But the new step-4 classification gate you added above passes unclassified traffic through silently: a request with no parser-populated extension yields!anyActionandContinuewithout judging. As written, the headline example can no longer be caught by IBAC, so the doc is internally inconsistent.Update the Threat Model and Limitations to reflect that IBAC now only fires on parser-classified traffic, or clarify how plain-HTTP exfiltration is expected to be classified.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@authbridge/docs/ibac-plugin.md` around lines 29 - 36, The threat-model doc is inconsistent with the new classification gate (!anyAction => Continue) so update the Threat Model and Limitations to state that IBAC (checked in OnRequest) only evaluates requests that parsers populate as actions; explicitly mention that plain-HTTP requests without parser enrichment will pass through (not be judged) unless a parser or MCP enrichment classifies them, and adjust the example that previously showed DenyStatus(403, "ibac.blocked") for a plain POST to either show it being classified first (e.g., via parser/MCP enrichment) or change the outcome to Continue; ensure references to pctx.Session.LastIntent() and the judge LLM pathway remain accurate when describing the classified path.authbridge/authlib/plugins/ibac/plugin_test.go (1)
290-304:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winThis test no longer exercises the
inference_bypasspath it names.
makePCtxinjects a defaultMCP{IsAction:true}, and this test leaves the newInferenceextension atIsAction:false. InClassification()that yieldsanyAction=true(MCP) andanyBypass=true(Inference), so the request short-circuits at the step-4protocol_mechanicsgate and never reaches the step-5 inference-bypass branch. The assertions (Continue,calls==0) pass either way, masking the gap.Mirror what
inference-parseractually does (IsAction:true) so the request passes the classification gate, and assert the skip reason.💚 Proposed fix
pctx := makePCtx(t) - pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "gpt-4o-mini"} + // inference-parser sets IsAction=true on populated extensions; keep + // anyBypass=false so we reach the step-5 inference operator-policy gate. + pctx.Extensions.MCP = nil + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "gpt-4o-mini", IsAction: true} action := invokeOnRequest(p, pctx) if action.Type != pipeline.Continue { t.Errorf("got %v, want Continue (inference bypassed by default)", action.Type) } if fj.calls != 0 { t.Errorf("judge invoked on inference; want 0") } + inv := lastInvocation(t, pctx) + if inv.Reason != "inference_bypass" { + t.Errorf("Invocation reason = %q, want 'inference_bypass'", inv.Reason) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@authbridge/authlib/plugins/ibac/plugin_test.go` around lines 290 - 304, The test TestOnRequest_InferenceBypassByDefault currently leaves the Inference extension IsAction=false so Classification() treats MCP.IsAction=true and Inference.IsAction=false as anyBypass=true and short-circuits before the inference-bypass branch; update the test to mirror the inference-parser by setting pctx.Extensions.Inference.IsAction = true (or construct the PCtx so the Inference extension has IsAction:true) so the request passes the classification gate and reaches the inference-bypass logic in OnRequest; then add an assertion that the returned action includes the expected skip reason (verifying the inference-bypass path was exercised) and keep the existing checks for action.Type and fj.calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@authbridge/authlib/plugins/ibac/plugin_test.go`:
- Around line 290-304: The test TestOnRequest_InferenceBypassByDefault currently
leaves the Inference extension IsAction=false so Classification() treats
MCP.IsAction=true and Inference.IsAction=false as anyBypass=true and
short-circuits before the inference-bypass branch; update the test to mirror the
inference-parser by setting pctx.Extensions.Inference.IsAction = true (or
construct the PCtx so the Inference extension has IsAction:true) so the request
passes the classification gate and reaches the inference-bypass logic in
OnRequest; then add an assertion that the returned action includes the expected
skip reason (verifying the inference-bypass path was exercised) and keep the
existing checks for action.Type and fj.calls.
In `@authbridge/docs/ibac-plugin.md`:
- Around line 29-36: The threat-model doc is inconsistent with the new
classification gate (!anyAction => Continue) so update the Threat Model and
Limitations to state that IBAC (checked in OnRequest) only evaluates requests
that parsers populate as actions; explicitly mention that plain-HTTP requests
without parser enrichment will pass through (not be judged) unless a parser or
MCP enrichment classifies them, and adjust the example that previously showed
DenyStatus(403, "ibac.blocked") for a plain POST to either show it being
classified first (e.g., via parser/MCP enrichment) or change the outcome to
Continue; ensure references to pctx.Session.LastIntent() and the judge LLM
pathway remain accurate when describing the classified path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b3aa6b65-30cc-4d8c-b86c-b3161f695d78
📒 Files selected for processing (15)
authbridge/CLAUDE.mdauthbridge/authlib/pipeline/context.goauthbridge/authlib/pipeline/extensions.goauthbridge/authlib/pipeline/extensions_test.goauthbridge/authlib/plugins/a2aparser/plugin.goauthbridge/authlib/plugins/a2aparser/plugin_test.goauthbridge/authlib/plugins/ibac/plugin.goauthbridge/authlib/plugins/ibac/plugin_test.goauthbridge/authlib/plugins/inferenceparser/plugin.goauthbridge/authlib/plugins/inferenceparser/plugin_test.goauthbridge/authlib/plugins/mcpparser/plugin.goauthbridge/authlib/plugins/mcpparser/plugin_test.goauthbridge/docs/framework-architecture.mdauthbridge/docs/ibac-plugin.mdauthbridge/docs/plugin-reference.md
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) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
mrsabath
left a comment
There was a problem hiding this comment.
Strong refactor — pushing classification into parsers and collapsing IBAC's three protocol-specific gates into one verdict is the right shape, and the pctx.Classification() contract is clean. CI green, DCO clean across 6 commits, and the synthetic $transport/* extension idiom for body-less MCP retrievals reads well.
Must-fix (both unaddressed CodeRabbit findings)
1. authbridge/docs/ibac-plugin.md — threat model contradicts the new gate (out-of-diff, lines ~23-39 and ~320-340).
The headline IBAC scenario describes a plain-HTTP POST evil-server/collect ("plain HTTP, not MCP, not inference traffic") being denied with 403 ibac.blocked. With the new step-4 classification gate, that exact request has no parser-populated extension, so pctx.Classification() returns (false, false), the !anyAction branch fires, and IBAC Continues silently with no Skip recorded.
The operational-flow update at lines 107-145 is correct, but it didn't propagate up to the threat model walkthrough or the Limitations note. Security-relevant doc accuracy issue — readers will assume IBAC catches plain-HTTP exfiltration when the gate now lets it through.
Fix: either rewrite the headline example to use MCP-shaped exfil (mcp-parser populates an MCPExtension{IsAction:true}, IBAC actually fires) or explicitly call out plain-HTTP exfil as out-of-scope under the new gate, with a pointer to the runtime classification semantics.
2. authbridge/authlib/plugins/ibac/plugin_test.go — TestOnRequest_InferenceBypassByDefault (out-of-diff, line ~299).
makePCtx (modified in this PR) sets Extensions.MCP = {Method: "tools/call", IsAction: true} by default. The test adds Inference{Model: "gpt-4o-mini"} which leaves IsAction: false. Under the new Classification():
- MCP populated with
IsAction:true→anyAction=true - Inference populated with
IsAction:false→anyBypass=true
Result: step-4 short-circuits with skip/protocol_mechanics. The step-5 inference-bypass branch is never reached. Assertions Continue + calls==0 still pass for the wrong reason. A regression in the inference-bypass logic would not be caught.
The 944838d commit fixed TestOnRequest_InferenceJudgedWhenEnabled (line ~322) but missed this sibling. Fix:
pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "gpt-4o-mini", IsAction: true}
// existing assertions ...
inv := lastInvocation(t, pctx)
if inv.Reason != "inference_bypass" {
t.Errorf("Invocation reason = %q, want %q", inv.Reason, "inference_bypass")
}Suggestion
Default fail-open on missing intent is a security-posture change not in the breaking-changes list.
The PR body's "Compatibility / breaking changes" section calls out RequiresAny and the additive wire-format fields, but no_intent_policy: "allow" as the new default flips the prior hardcoded 403 ibac.no_intent (fail closed) to skip/no_user_context + Continue (fail open). Operators upgrading without setting no_intent_policy: "deny" will see previously-blocked traffic now passing through. Worth a row in the breaking-changes list and a callout in ibac-plugin.md Limitations.
Inline comment below covers a smaller RequiresAny modeling concern.
Areas reviewed: Go pipeline framework (Classification, RequiresAny), plugin classification contract, IBAC refactor, mcp-parser synthetic transport extensions, threat-model doc, plugin reference doc, demo pipeline composition (demos/ibac/k8s/ibac-patch.yaml), prior CodeRabbit feedback follow-through.
Commits: 6, all signed off ✓
CI status: All 19 checks passing.
| // 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, |
There was a problem hiding this comment.
suggestion: RequiresAny validation is same-chain (per pipeline/plugin.go:65-76). IBAC runs in the outbound chain, but a2a-parser runs in the inbound chain in every in-tree config — see demos/ibac/k8s/ibac-patch.yaml:
inbound_prepend:
- name: a2a-parser # <-- inbound
outbound_append:
- name: inference-parser
- name: mcp-parser
- name: ibac # <-- outboundIncluding a2a-parser in IBAC's RequiresAny means an outbound chain [a2a-parser, ibac] would pass the validator, but a2a-parser running outbound populates nothing IBAC reads (no Session.Intents to feed step 6). The realistic outbound parsers are mcp-parser and inference-parser.
Two options: (a) drop a2a-parser from RequiresAny (more accurate — only the parsers that actually populate extensions in the outbound chain count), or (b) keep it but document in the doc-comment block above that this entry exists only to satisfy the boot-time validator — the functional a2a-parser dependency comes from the inbound chain and remains a runtime check governed by no_intent_policy.
… posture docs Four review-feedback follow-ups in one commit, scoped to IBAC plus its demo manifest and reference docs. Build + test pass. 1. Must-fix kagenti#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 kagenti#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 kagenti#450 (already in main), not kagenti#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) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@authbridge/docs/ibac-plugin.md`:
- Around line 188-190: The Request Flow step 4 description is out of sync with
the new `unclassified_policy` option: update the flow text so that when
`!anyAction` you conditionally branch on `unclassified_policy` (e.g., if
`unclassified_policy == "passthrough"` then Continue silently, if `"judge"` then
fall through to the judge) instead of always continuing; reference the
`unclassified_policy` setting and the `!anyAction` check in the step text and
briefly describe both branching outcomes so operators see the runtime behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 5365839d-8eae-4236-bc67-597e23cfc956
📒 Files selected for processing (4)
authbridge/authlib/plugins/ibac/plugin.goauthbridge/authlib/plugins/ibac/plugin_test.goauthbridge/demos/ibac/k8s/ibac-patch.yamlauthbridge/docs/ibac-plugin.md
🚧 Files skipped from review as they are similar to previous changes (1)
- authbridge/authlib/plugins/ibac/plugin_test.go
There was a problem hiding this comment.
🧹 Nitpick comments (1)
authbridge/demos/ibac/Makefile (1)
119-121: ⚡ Quick winInclude module files in staleness detection for
abctlrebuilds.On Line 119, rebuild freshness only checks
*.gounder../../cmd/abctl. Ifgo.mod/go.sumchanges, the binary can be stale but still be treated as up-to-date.Suggested patch
build-abctl: ## Build abctl into /tmp (rebuilds when source is newer; used by show-result) `@ABCTL_SRC_DIR`=../../cmd/abctl; \ + ABCTL_MOD_DIR=../..; \ 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 \ + if [ -z "$$NEWER" ] && [ ! "$$ABCTL_MOD_DIR/go.mod" -nt /tmp/abctl-ibac-demo ] && [ ! "$$ABCTL_MOD_DIR/go.sum" -nt /tmp/abctl-ibac-demo ]; 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; \Also applies to: 128-128
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@authbridge/demos/ibac/Makefile` around lines 119 - 121, The staleness check for rebuilding abctl only finds '*.go' files (variable NEWER using $$ABCTL_SRC_DIR) so changes to go.mod or go.sum are ignored; update the find invocation that computes NEWER (and the similar block later) to also consider go.mod and go.sum (e.g., include -name 'go.mod' -o -name 'go.sum' or use -type f with specific patterns) so the /tmp/abctl-ibac-demo freshness test correctly detects module file changes and forces rebuilds when go.mod/go.sum change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@authbridge/demos/ibac/Makefile`:
- Around line 119-121: The staleness check for rebuilding abctl only finds
'*.go' files (variable NEWER using $$ABCTL_SRC_DIR) so changes to go.mod or
go.sum are ignored; update the find invocation that computes NEWER (and the
similar block later) to also consider go.mod and go.sum (e.g., include -name
'go.mod' -o -name 'go.sum' or use -type f with specific patterns) so the
/tmp/abctl-ibac-demo freshness test correctly detects module file changes and
forces rebuilds when go.mod/go.sum change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c48cccca-1895-4577-a74d-aea227b5e6b4
📒 Files selected for processing (1)
authbridge/demos/ibac/Makefile
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) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
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) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
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) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
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) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
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) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
… posture docs Four review-feedback follow-ups in one commit, scoped to IBAC plus its demo manifest and reference docs. Build + test pass. 1. Must-fix kagenti#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 kagenti#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 kagenti#450 (already in main), not kagenti#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) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
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) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
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 → <UUID> 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) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
7ccd1be to
eec27ad
Compare
mrsabath
left a comment
There was a problem hiding this comment.
Follow-up commits comprehensively address all prior review feedback.
| Prior finding | Status |
|---|---|
Must-fix #1: TestOnRequest_InferenceBypassByDefault passing for wrong reason |
Fixed in f001507 — IsAction:true on inference + assertion on inv.Reason == "inference_bypass" |
| Must-fix #2: ibac-plugin.md threat model contradicts new gate | Fixed in f001507 — new unclassified_policy config knob + demo manifest opts in to "judge" + Default Security Posture doc section |
Suggestion: RequiresAny cross-chain modeling |
Fixed in f001507 — a2a-parser dropped from RequiresAny; doc-comment explains the cross-chain runtime dependency governed by no_intent_policy |
Suggestion: no_intent_policy posture flip undocumented |
Fixed in f001507 — Default Security Posture section in ibac-plugin.md covers both fail-open knobs |
The new unclassified_policy config knob is the cleanest resolution to the "plain-HTTP exfil under defense-in-depth" tension — defaults to "passthrough" (clean defense in depth for production), opts into "judge" for the demo's threat-model walkthrough. New tests cover both branches plus default-validation (TestOnRequest_UnclassifiedPolicy_Judge, TestConfigure_UnclassifiedPolicy_*). Doc updates to ibac-plugin.md (Default Security Posture, Threat-coverage notes) and plugin-reference.md (Classifying requests contract) are thorough.
Unrelated demo improvements (f1e28b0 abctl rebuild-on-source-change; eec27ad mint contextId per A2A spec §6.6) are sensible and well-scoped.
Areas reviewed: Go pipeline framework (Classification, RequiresAny scope), IBAC classification gate, mcp-parser synthetic transport extensions + path config, threat-model docs, demo manifest, prior-review delta.
Commits: 9, all signed off.
CI status: All 19 checks passing.
Summary
Move protocol-classification logic out of guardrail plugins (IBAC today) and into the parsers that own each protocol. Adds a single
IsAction boolfield on each protocol extension and apctx.Classification()helper that aggregates the verdict across all populated extensions. IBAC's three protocol-specific bypass steps collapse to a single classification gate.Why
Before: IBAC encoded MCP-protocol vocabulary inline (
isMCPHousekeepinglisting every housekeeping method,isTransportRetrievalfor body-less GET/HEAD/OPTIONS detection, the body-less DELETE+Mcp-Session-Idcheck from PR #451). Every new guardrail (rate limiter, audit logger, secret scanner, …) and every new protocol (A2A subroutes, OTEL, gRPC) would multiply this work.After: each parser owns its protocol's "is this an action vs protocol mechanics" answer; guardrails read one verdict and decide. Adding a guardrail is now zero protocol-specific work; adding a protocol's classification rules touches only that parser.
What changes
Five logical commits, each independently reviewable:
feat(pipeline): Add IsAction classification on protocol extensions— the wire. AddsIsAction booltoMCPExtension,A2AExtension,InferenceExtension. Addspctx.Classification() (anyAction, anyBypass bool)aggregating across populated extensions. No behavior change yet — every parser still leavesIsActionat the zero value.feat(mcp-parser): Classify methods + recognize transport via path config— the largest change. mcp-parser now:IsAction = truefortools/call,prompts/get,resources/read; everything else stays at default false.pathsconfig field (default["/mcp"]) for body-less transport detection.Mcp-Session-Idon a configured path, populates a syntheticMCPExtension{Method: "$transport/terminate"}(replaces the logic from PR fix(ibac): Bypass MCP session-terminate DELETE as transport_stream #451).MCPExtension{Method: "$transport/stream"}(replaces the body-less GET case from PR fix(ibac): Bypass transport-layer calls and let agents take self-actions #450).$prefix on synthetic methods is reserved (real MCP methods followcategory/actionwith no$), so operators can tell at a glance these aren't methods that appeared on the wire.feat(a2a-parser): Classify outbound A2A methods— setsIsAction = trueformessage/sendandmessage/stream; protocol/discovery methods inherit default false.feat(inference-parser): Mark all populated requests as actions— every populatedInferenceExtensionis an outbound LLM call; setsIsAction = trueunconditionally. The "don't judge inference by default" decision lives separately in IBAC'sjudge_inferenceoperator-config flag — classification (parser concern) and operator policy (guardrail concern) stay cleanly separated.refactor(ibac): Replace inline protocol logic with classification gate— the simplification. Removes:isMCPHousekeeping(now an mcp-parser concern; PR fix(ibac): Bypass MCP session-terminate DELETE as transport_stream #451'sresources/subscribe/resources/unsubscribeadditions follow).isTransportRetrievaland the body-less DELETE+header check (now mcp-parser's synthetic transport extensions).OnRequest.Adds the new step 4: classification gate driven by
pctx.Classification(). UpdatesCapabilities()to declareRequiresAny: ["mcp-parser", "a2a-parser", "inference-parser"](pipeline boot-fails if no parser is in the chain — without one, IBAC has no classification to read and would silently no-op on every request).End-state guardrail logic
That's it. No protocol-specific code in IBAC. Same five lines work for any future guardrail.
Test plan
go test ./...inauthbridge/authlib— all 29 packages pass.$transport/terminate; body-less GET →$transport/stream; non-configured-path / no-header / body-less-POST cases all decline to claim;pathsconfig defaults + custom + bad-glob rejection.message/send,message/stream, plus a representative non-action.IsAction = trueunconditionally.Classification()on empty pctx, single-extension action, single-extension bypass, multi-extension agreement, multi-extension mixed.TestOnRequest_ClassificationBypass_ProtocolMechanics,TestOnRequest_NoClassification_PassesThrough,TestOnRequest_MCPActionReachesJudge,TestOnRequest_DescribeActionHasNoLeadingWhitespace(regression-locks the listenerpctx.Methodplumbing from PR fix(ibac): Bypass transport-layer calls and let agents take self-actions #450). Existing tests updated to populateIsActionexplicitly where they expect to reach the judge.exgentic-a2a-tool-calling-gsm8kagent (procedure same as PR fix(ibac): Bypass transport-layer calls and let agents take self-actions #450/fix(ibac): Bypass MCP session-terminate DELETE as transport_stream #451):./deploy-and-evaluate.sh --benchmark gsm8k --agent tool_calling --plugin-preset ibac-only.:9094/v1/sessions/<id>that MCP setup methods recordskip/protocol_mechanics,tools/callis judged, body-less GET on/mcprecordsskip/protocol_mechanics(synthetic$transport/stream), DELETE on/mcpwithMcp-Session-Idrecordsskip/protocol_mechanics(synthetic$transport/terminate).RequiresAnyerror.Compatibility / breaking changes
RequiresAnyon IBAC. Any existing IBAC-only pipeline (no parsers in the same outbound chain) will fail to boot after this change. Such pipelines were silently no-op anyway — IBAC had nothing to read — so the breakage catches misconfig rather than introducing it.pathsconfig defaults to["/mcp"], which matches the standard MCP Streamable HTTP setup used by all in-tree demos (weather-agent,github-issue,ibac). Operators with non-default endpoint paths add explicit entries.isAction); abctl renders unknown JSON opaquely so existing UI keeps working.#451(still open) becomes redundant if this PR lands first. The MCP session-terminate logic moves from IBAC into mcp-parser as the synthetic$transport/terminateextension.Out of scope (deferred)
IsActionmeans a missing entry produces silent under-judging.$-prefixed synthetic methods (a "synthetic transport" badge or different color). Functional today as opaque literal string.SecurityExtension/DelegationExtension/Custom. Those aren't protocol parsers; classification doesn't apply.Assisted-By: Claude (Anthropic AI) noreply@anthropic.com
Summary by CodeRabbit
New Features
Tests
Documentation
Chores