Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions authbridge/authlib/listener/extproc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ func (s *Server) handleInbound(stream extprocv3.ExternalProcessor_ProcessServer,
ctx := stream.Context()
pctx := &pipeline.Context{
Direction: pipeline.Inbound,
Method: getHeader(headers, ":method"),
Scheme: getHeader(headers, ":scheme"),
Path: getHeader(headers, ":path"),
Headers: headerMapToHTTP(headers),
Expand All @@ -165,6 +166,7 @@ func (s *Server) handleInboundBody(stream extprocv3.ExternalProcessor_ProcessSer
ctx := stream.Context()
pctx := &pipeline.Context{
Direction: pipeline.Inbound,
Method: getHeader(headers, ":method"),
Scheme: getHeader(headers, ":scheme"),
Path: getHeader(headers, ":path"),
Headers: headerMapToHTTP(headers),
Expand Down Expand Up @@ -446,6 +448,7 @@ func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer
ctx := stream.Context()
pctx := &pipeline.Context{
Direction: pipeline.Outbound,
Method: getHeader(headers, ":method"),
Scheme: getHeader(headers, ":scheme"),
Host: getHeader(headers, ":authority"),
Path: getHeader(headers, ":path"),
Expand Down Expand Up @@ -483,6 +486,7 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe
ctx := stream.Context()
pctx := &pipeline.Context{
Direction: pipeline.Outbound,
Method: getHeader(headers, ":method"),
Scheme: getHeader(headers, ":scheme"),
Host: getHeader(headers, ":authority"),
Path: getHeader(headers, ":path"),
Expand Down
4 changes: 3 additions & 1 deletion authbridge/authlib/listener/forwardproxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {

pctx := &pipeline.Context{
Direction: pipeline.Outbound,
Method: r.Method,
Scheme: r.URL.Scheme,
Host: r.Host,
Path: r.URL.Path,
Expand Down Expand Up @@ -410,7 +411,8 @@ const connectDialTimeout = 30 * time.Second
func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
pctx := &pipeline.Context{
Direction: pipeline.Outbound,
Scheme: "tcp", // marker: bytes are opaque, not HTTP
Method: r.Method, // always "CONNECT" here, but populated for parity with handleRequest
Scheme: "tcp", // marker: bytes are opaque, not HTTP
Host: r.Host,
Path: "",
Headers: r.Header.Clone(),
Expand Down
1 change: 1 addition & 0 deletions authbridge/authlib/listener/reverseproxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ func (s *Server) Handler() http.Handler {
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
pctx := &pipeline.Context{
Direction: pipeline.Inbound,
Method: r.Method,
Scheme: requestScheme(r),
Host: r.Host,
Path: r.URL.Path,
Expand Down
133 changes: 128 additions & 5 deletions authbridge/authlib/plugins/ibac/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,40 @@ type ibacConfig struct {
// BypassPaths are URL path globs skipped without judging.
// Defaults to bypass.DefaultPatterns (.well-known, healthz, etc).
BypassPaths []string `json:"bypass_paths"`

// NoIntentPolicy controls behavior when a request reaches step 6
// without a recorded user intent — either because Session is nil
// (no inbound A2A turn has populated the active bucket yet) or
// because the session contains no extractable user-role A2A
// fragment. Two values:
//
// - "allow" (default): Skip with reason "no_user_context" and
// pass through. Right for deployments where agents take
// legitimate self-actions (initialization, machine-to-machine
// calls, headless cron-driven flows) — IBAC should not be in
// the middle of those decisions because there's no user
// intent to align against.
//
// - "deny": Reject with 403 and the existing "no_session" /
// "no_intent" reasons. Right for deployments where every
// outbound is supposed to be user-driven and a missing intent
// is a real misconfiguration worth surfacing as a denial.
//
// The default is "allow" because IBAC's threat model targets
// prompt-injection attacks where the LLM emits user-misaligned
// actions — that requires there to be a user in the first place.
// Deployments that mix user-driven and self-driven traffic get
// the right behavior automatically; deployments that want hard
// fail-closed semantics opt in via "deny".
NoIntentPolicy string `json:"no_intent_policy"`
}

// no_intent_policy values.
const (
NoIntentPolicyAllow = "allow"
NoIntentPolicyDeny = "deny"
)

// defaultBypassHosts is the conservative starting set. Operators with
// SPIRE / Keycloak / observability stacks deployed under different
// service names extend this via config.BypassHosts.
Expand Down Expand Up @@ -120,6 +152,9 @@ func (c *ibacConfig) applyDefaults() {
if len(c.BypassPaths) == 0 {
c.BypassPaths = defaultBypassPaths
}
if c.NoIntentPolicy == "" {
c.NoIntentPolicy = NoIntentPolicyAllow
}
Comment thread
huang195 marked this conversation as resolved.
}

func (c *ibacConfig) validate() error {
Expand Down Expand Up @@ -155,6 +190,13 @@ func (c *ibacConfig) validate() error {
"if you mean to disable IBAC, remove it from the pipeline instead", p)
}
}
switch c.NoIntentPolicy {
case NoIntentPolicyAllow, NoIntentPolicyDeny:
// ok
default:
return fmt.Errorf("no_intent_policy must be %q or %q, got %q",
NoIntentPolicyAllow, NoIntentPolicyDeny, c.NoIntentPolicy)
}
return nil
}

Expand Down Expand Up @@ -261,17 +303,69 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A
return pipeline.Action{Type: pipeline.Continue}
}

// 5b. Transport-stream bypass. Body-less retrieval-shaped requests
// carry no action payload to evaluate — typical patterns: MCP
// Streamable HTTP's server→client SSE channel (GET), agent-
// card fetches (GET), OAuth metadata probes (GET), CORS
// preflights (OPTIONS), HEAD probes for cache validation /
// existence checks. Sending these to the judge is a category
// error: there's nothing to judge, and the LLM either denies
// for lack of context or returns a non-deterministic verdict
// that depends on noise in the prompt.
//
// Threat model: an attacker can't smuggle a payload through a
// body-less GET/HEAD/OPTIONS — there's no request body to put
// the action in. Side-effect HTTP methods (POST/PUT/DELETE/
// PATCH) always reach the judge, body-having or not.
//
// 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 isTransportRetrieval(pctx.Method) && len(pctx.Body) == 0 {
pctx.Skip("transport_stream")
return pipeline.Action{Type: pipeline.Continue}
}

// 6. Pull the user's most recent declared intent. Two distinct
// nil pathways, both fail closed but with different reasons so
// operator dashboards can tell them apart:
// nil pathways, distinguished so operator dashboards can tell
// them apart:
// - no_session: no inbound A2A request has populated the
// active session bucket yet (or session tracking is off
// entirely). Common at agent startup before any user turn.
// - no_intent: a session exists but contains no extractable
// user message (a2a-parser missing from inbound chain, or
// events present but none are user-role A2A requests).
//
// Both states mean "IBAC has no user intent to align against"
// — i.e., the request is either genuinely user-less (agent
// self-action, machine-to-machine, headless cron) or there's
// a misconfiguration upstream. NoIntentPolicy controls which
// interpretation wins:
// - allow (default): treat as self-action, Skip and Continue.
// Right for deployments that mix user-driven and self-driven
// traffic.
// - deny: treat as misconfiguration, Reject 403. Right for
// deployments where every outbound is user-driven and a
// missing intent should surface as a hard failure.
if pctx.Session == nil {
action := describeAction(pctx, p.cfg.JudgeInference)
if p.cfg.NoIntentPolicy == NoIntentPolicyAllow {
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionSkip,
Phase: pipeline.InvocationPhaseRequest,
Reason: "no_user_context",
Details: map[string]string{
"action": action,
"sub_reason": "no_session",
"explanation": "no active session — treating as agent self-action per no_intent_policy=allow",
},
})
return pipeline.Action{Type: pipeline.Continue}
}
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionDeny,
Phase: pipeline.InvocationPhaseRequest,
Expand All @@ -286,6 +380,19 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A
intentText := extractIntentText(intent)
if intentText == "" {
action := describeAction(pctx, p.cfg.JudgeInference)
if p.cfg.NoIntentPolicy == NoIntentPolicyAllow {
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionSkip,
Phase: pipeline.InvocationPhaseRequest,
Reason: "no_user_context",
Details: map[string]string{
"action": action,
"sub_reason": "no_intent",
"explanation": "session exists but no user intent — treating as agent self-action per no_intent_policy=allow",
},
})
return pipeline.Action{Type: pipeline.Continue}
}
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionDeny,
Phase: pipeline.InvocationPhaseRequest,
Expand All @@ -297,11 +404,11 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A
return pipeline.DenyStatus(403, "ibac.no_intent", "no recorded user intent")
}

// 6. Build action description and call judge.
// 7. Build action description and call judge.
action := describeAction(pctx, p.cfg.JudgeInference)
verdict, reason, err := p.judge.Evaluate(ctx, intentText, action)

// 7. Fail closed on judge errors. Two flavors, distinguished
// 8. Fail closed on judge errors. Two flavors, distinguished
// via the ErrJudgeUncertain sentinel so operator dashboards
// don't conflate model-output bugs with infra outages:
// - uncertain: judge is up but emitted unparseable / unknown
Expand Down Expand Up @@ -341,7 +448,7 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A
return pipeline.DenyStatus(503, "ibac.judge_unavailable", errPreview)
}

// 8. Apply verdict.
// 9. Apply verdict.
if verdict == "deny" {
pctx.Record(pipeline.Invocation{
Action: pipeline.ActionDeny,
Expand Down Expand Up @@ -491,6 +598,22 @@ func isMCPHousekeeping(method string) bool {
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
}

// 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 {
Expand Down
Loading
Loading