Skip to content
Merged
2 changes: 2 additions & 0 deletions authbridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions authbridge/authlib/pipeline/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,67 @@ func (c *Context) ContentSources() []contracts.ContentSource {
return out
}

// Classification reports the request's protocol classification, aggregated
// across every populated protocol extension on Extensions:
//
// - anyAction is true if at least one populated extension has IsAction=true
// (e.g. mcp-parser saw "tools/call"; inference-parser saw any inference call).
// - anyBypass is true if at least one populated extension has IsAction=false
// (e.g. mcp-parser saw "tools/list" or a $transport/* synthetic event).
//
// Both false means no parser populated anything and the request is
// unclassified — guardrails treating IBAC-style defense in depth (only
// fire on traffic a parser claimed) should pass through.
//
// Parser-disjointness assumption. The current in-tree parsers fire on
// disjoint request shapes — mcp-parser on JSON-RPC bodies (or body-
// less MCP-shaped requests on configured paths), a2a-parser on A2A
// JSON-RPC bodies, inference-parser on /v1/{chat/,}completions paths
// — so a single request typically populates at most one extension.
// The aggregation above is defensive (handles the multi-extension
// case if a future hybrid transport ever does double-claim), but
// parser authors should not rely on the aggregation as a feature: a
// parser that populates an extension on a request another parser
// already classified breaks the contract that classification belongs
// to whichever parser owns the wire shape.
//
// Conflict resolution. If anyAction && anyBypass both end up true,
// callers decide their own precedence:
//
// - Defense-in-depth gates (IBAC, rate limiters): treat anyBypass
// as winning — skip first. Safer default when you can't tell who
// to trust.
// - Audit-style guardrails: probably want to log the action even
// if some extension said bypass; flip the precedence.
//
// Either choice is valid; the contract here just provides both signals.
// In practice the question rarely comes up because of the disjointness
// above.
func (c *Context) Classification() (anyAction, anyBypass bool) {
if ext := c.Extensions.MCP; ext != nil {
if ext.IsAction {
anyAction = true
} else {
anyBypass = true
}
}
if ext := c.Extensions.A2A; ext != nil {
if ext.IsAction {
anyAction = true
} else {
anyBypass = true
}
}
if ext := c.Extensions.Inference; ext != nil {
if ext.IsAction {
anyAction = true
} else {
anyBypass = true
}
}
return anyAction, anyBypass
}

// emitBodyMutation records the Invocation and publishes the
// plugin-public event carrying length delta + sha256 before/after.
// Never logs raw body bytes — the session store is unauthenticated.
Expand Down
33 changes: 28 additions & 5 deletions authbridge/authlib/pipeline/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"`
Expand All @@ -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.
Expand All @@ -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"`
Expand All @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions authbridge/authlib/pipeline/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
22 changes: 20 additions & 2 deletions authbridge/authlib/plugins/a2aparser/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
34 changes: 34 additions & 0 deletions authbridge/authlib/plugins/a2aparser/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Loading
Loading