Skip to content

Commit 4695245

Browse files
authored
Merge pull request #500 from kellyaa/fix/ibac-intent-eviction
Fix: Pin IBAC user intent against FIFO eviction; add listener.skip_hosts
2 parents 2fce417 + e8e632d commit 4695245

15 files changed

Lines changed: 1390 additions & 92 deletions

File tree

authbridge/CLAUDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,19 @@ See [`docs/framework-architecture.md`](docs/framework-architecture.md#9-config-h
579579

580580
10. **Outbound passthrough is the safe default**: The `DEFAULT_OUTBOUND_POLICY` defaults to `passthrough`, which means outbound traffic to LLM inference endpoints (e.g., Ollama via `host.docker.internal`) passes through without token exchange. If this were set to `exchange`, all outbound HTTP calls would attempt token exchange and fail for non-Keycloak destinations.
581581

582+
11. **Chatty observability traffic and IBAC user intent**: The session store is FIFO with a default cap of 100 events per session. Two layered defenses keep the inbound A2A user intent visible to IBAC even when an agent generates dozens of outbound events per turn:
583+
584+
- **Primary fix — `listener.skip_hosts`**: list infrastructure destinations (OTel collectors, metrics endpoints, log shippers) whose traffic should bypass the pipeline AND session recording entirely. Matched requests are forwarded as a transparent proxy: no plugin runs, no event is appended. Patterns use the same `.`-delimited glob semantics as `authproxy-routes`; ports are stripped before matching. Example:
585+
```yaml
586+
listener:
587+
skip_hosts:
588+
- "otel-collector.*.svc.cluster.local"
589+
- "*.metrics.local"
590+
```
591+
Any change to `listener.skip_hosts` requires a pod restart (same rule as other `listener.*` fields). Do NOT add hosts here that need IBAC / token-exchange policy applied — bypass means bypass.
592+
593+
- **Backstop — intent pin in the eviction policy**: even with `skip_hosts` empty, the session store now pins the most-recent inbound A2A request event against FIFO eviction. If the buffer overflows, every other event evicts in normal chronological order; the protected intent stays at its original timestamp, leaving a visible time gap in the timeline. Older intents from earlier turns are NOT pinned — only the latest one — so a multi-turn conversation with huge fan-out can't pile up stale intents and starve the buffer. The pin protects against FIFO eviction only: IBAC's `LastIntent()` survives buffer overflow as long as the session is still alive and an inbound A2A request has landed in it, but can still return nil after session expiry, explicit deletion, or before the first inbound request arrives. The pin is defense-in-depth; reach for `skip_hosts` first when the offending traffic is identifiable infrastructure.
594+
582595
## DCO Sign-Off (Mandatory)
583596

584597
All commits **must** include a `Signed-off-by` trailer (Developer Certificate of Origin).

authbridge/authlib/config/config.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,67 @@ type ListenerConfig struct {
334334
// (JSON snapshots + SSE stream consumed by abctl or curl). Default per
335335
// mode preset is ":9094". Set to empty string to disable the endpoint.
336336
SessionAPIAddr string `yaml:"session_api_addr" json:"session_api_addr"`
337+
338+
// SkipHosts lists outbound destination host patterns whose traffic
339+
// bypasses the plugin pipeline AND session recording entirely. The
340+
// listener forwards matched requests as a transparent proxy without
341+
// running plugins or appending events to any session bucket.
342+
//
343+
// Intended for high-volume infrastructure traffic that competes
344+
// with agent-meaningful events for session-buffer slots. The
345+
// canonical example: an OpenTelemetry collector sidecar that emits
346+
// dozens of exports per agent turn — without this gate, those
347+
// exports occupy the session buffer's FIFO eviction window and
348+
// silently push out the inbound A2A user intent that IBAC needs
349+
// to align tool calls against, causing IBAC to fall through to
350+
// the no_intent skip path on every call after the first.
351+
//
352+
// Patterns use `.`-delimited glob semantics (same library as
353+
// `authproxy-routes`): "otel-collector*" matches the short
354+
// service name, "otel-collector.kagenti-system.svc.cluster.local"
355+
// matches the FQDN, "*-collector" matches any single-label name
356+
// ending in -collector. Port is stripped before matching, so
357+
// patterns must NOT include `:port`.
358+
//
359+
// Empty list (default) preserves current behavior: every outbound
360+
// host runs the pipeline and is eligible for session recording.
361+
//
362+
// Trust model — the value matched against SkipHosts is the
363+
// destination Host as observed at the listener boundary, which is
364+
// agent-influenceable in two of the three deployment shapes:
365+
//
366+
// - ext_proc / envoy-sidecar: matches Envoy's `:authority`
367+
// (fallback `host` header). The agent sets these; Envoy may
368+
// rewrite them per its config, but ultimately the value is
369+
// "what the agent told Envoy it wanted to talk to."
370+
// - HTTP forward-proxy / proxy-sidecar: matches `r.Host` from
371+
// the HTTP request. The request is then dialed against
372+
// `r.URL`, so a forged Host that diverges from the real URL
373+
// host would skip-match yet send to the actual upstream.
374+
// - CONNECT-tunnel / proxy-sidecar: safer-by-construction —
375+
// `r.Host` IS the dial target. A forged Host cannot
376+
// skip-match while dialing elsewhere.
377+
//
378+
// Implication: do NOT list a destination here that you'd want
379+
// IBAC / token-exchange to deny on. Skip means "the operator
380+
// trusts every flow to this host enough to bypass the entire
381+
// outbound enforcement pipeline." Limit entries to infrastructure
382+
// destinations the agent should not be making policy decisions
383+
// against in the first place (collector sidecars, log shippers).
384+
//
385+
// Each skip is logged at INFO with the matched host and pattern
386+
// so an operator reviewing logs can see when a pattern fired and
387+
// catch unexpected matches early. The `transparentproxy` listener
388+
// (proxy-sidecar enforce-redirect mode) intentionally does NOT
389+
// consult SkipHosts — that is the hard egress guard and must not
390+
// be self-exemptable via the agent's outbound destination.
391+
//
392+
// Match-all patterns ("*", "**", whitespace-only) and patterns
393+
// containing ":port" are rejected at startup so a single
394+
// misconfigured entry can't silently disable all outbound
395+
// enforcement. Mirrors the bypass-pattern guard added to ibac
396+
// in #496.
397+
SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"`
337398
}
338399

339400
// StatsConfig represents the configuration for reporting config and statistics

authbridge/authlib/listener/extproc/server.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323

2424
"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
2525
"github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/internal/sseframe"
26+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/skiphost"
2627
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
2728
"github.com/kagenti/kagenti-extensions/authbridge/authlib/session"
2829
)
@@ -41,6 +42,15 @@ type Server struct {
4142
OutboundPipeline *pipeline.Holder
4243
Sessions *session.Store // nil when session tracking is disabled
4344
Shared pipeline.SharedStore // process-scoped store; set by main, may be nil
45+
46+
// SkipHosts, when non-nil and matching pctx.Host on an outbound
47+
// request, causes the listener to return passResponse() / nil pctx
48+
// immediately — bypassing the pipeline AND session recording for
49+
// that request. Forward the bytes; do nothing else. See
50+
// authlib/config/config.go ListenerConfig.SkipHosts for the
51+
// motivating case (OTel-collector traffic evicting the inbound
52+
// A2A intent from the session buffer's FIFO window).
53+
SkipHosts *skiphost.Matcher
4454
}
4555

4656
// Process handles the bidirectional ext_proc stream.
@@ -469,6 +479,15 @@ func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer
469479
pctx.Host = getHeader(headers, "host")
470480
}
471481

482+
// SkipHosts short-circuit: forward the request as a transparent
483+
// proxy without running the pipeline or recording a session event.
484+
// pctx=nil signals the response handlers (handleResponseHeaders,
485+
// handleResponseBody) and the deferred RunFinish to no-op as well —
486+
// all four phases are skipped consistently. See ListenerConfig.SkipHosts.
487+
if s.SkipHosts.Match(pctx.Host) {
488+
return passResponse(), nil
489+
}
490+
472491
if s.Sessions != nil {
473492
if aid := s.Sessions.ActiveSession(); aid != "" {
474493
pctx.Session = s.Sessions.View(aid)
@@ -509,6 +528,18 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe
509528
pctx.Host = getHeader(headers, "host")
510529
}
511530

531+
// SkipHosts short-circuit: see handleOutbound for rationale. The
532+
// body-phase entry point needs the same gate because Envoy may
533+
// deliver the body in a separate ProcessingRequest message even
534+
// when the headers were already passed through — without checking
535+
// here, a skip-listed host whose request carries a body would still
536+
// run the pipeline on the body phase.
537+
if pat, matched := s.SkipHosts.MatchPattern(pctx.Host); matched {
538+
slog.Info("ext_proc: skip_hosts match (body phase) — bypassing pipeline + session recording",
539+
"host", pctx.Host, "pattern", pat, "path", pctx.Path)
540+
return allowBodyResponse(), nil
541+
}
542+
512543
if s.Sessions != nil {
513544
if aid := s.Sessions.ActiveSession(); aid != "" {
514545
pctx.Session = s.Sessions.View(aid)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package extproc
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
"testing"
7+
"time"
8+
9+
extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
10+
11+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/skiphost"
12+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
13+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/plugintesting"
14+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/session"
15+
)
16+
17+
// markerPlugin records one Invocation per OnRequest call so tests can
18+
// assert whether the outbound pipeline ran. Mirrors the helper in the
19+
// forwardproxy skiphost tests; kept local to avoid a public test-only
20+
// type in plugintesting.
21+
type markerPlugin struct {
22+
calls atomic.Int32
23+
}
24+
25+
func (p *markerPlugin) Name() string { return "marker" }
26+
func (p *markerPlugin) Capabilities() pipeline.PluginCapabilities { return pipeline.PluginCapabilities{} }
27+
func (p *markerPlugin) OnResponse(context.Context, *pipeline.Context) pipeline.Action {
28+
return pipeline.Action{Type: pipeline.Continue}
29+
}
30+
31+
func (p *markerPlugin) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action {
32+
p.calls.Add(1)
33+
pctx.Record(pipeline.Invocation{
34+
Plugin: "marker",
35+
Action: pipeline.ActionObserve,
36+
Phase: pipeline.InvocationPhaseRequest,
37+
Reason: "ran",
38+
})
39+
return pipeline.Action{Type: pipeline.Continue}
40+
}
41+
42+
func newSkipServer(t *testing.T, store *session.Store, skip *skiphost.Matcher) (*Server, *markerPlugin) {
43+
t.Helper()
44+
inbound, err := plugintesting.BuildPipeline([]pipeline.Plugin{})
45+
if err != nil {
46+
t.Fatalf("building inbound pipeline: %v", err)
47+
}
48+
mp := &markerPlugin{}
49+
outbound, err := plugintesting.BuildPipeline([]pipeline.Plugin{mp})
50+
if err != nil {
51+
t.Fatalf("building outbound pipeline: %v", err)
52+
}
53+
return &Server{
54+
InboundPipeline: pipeline.NewHolder(inbound),
55+
OutboundPipeline: pipeline.NewHolder(outbound),
56+
Sessions: store,
57+
SkipHosts: skip,
58+
}, mp
59+
}
60+
61+
// TestExtProc_SkipHosts_OutboundBypass asserts the headers-only outbound
62+
// path: a SkipHosts-matched destination produces zero plugin invocations
63+
// and zero session events, and the response is a plain pass-through.
64+
// The motivating case is OTel-collector traffic in envoy-sidecar
65+
// deployments — without this gate, every export from the agent would
66+
// run the pipeline and append a session event, evicting the inbound
67+
// A2A user intent from the FIFO buffer.
68+
func TestExtProc_SkipHosts_OutboundBypass(t *testing.T) {
69+
store := session.New(5*time.Minute, 100, 0)
70+
defer store.Close()
71+
72+
// Match the agent's exgentic-style FQDN pattern: a leading-* glob
73+
// against the fixed suffix is the operator-friendly way to write
74+
// this and matches the hostname after net.SplitHostPort strips the
75+
// :8335 from pctx.Host.
76+
skip, err := skiphost.New([]string{"otel-collector.*.svc.cluster.local"})
77+
if err != nil {
78+
t.Fatalf("skiphost.New: %v", err)
79+
}
80+
81+
srv, mp := newSkipServer(t, store, skip)
82+
83+
stream := &mockStream{
84+
ctx: context.Background(),
85+
requests: []*extprocv3.ProcessingRequest{
86+
outboundRequest(makeHeaders(
87+
":authority", "otel-collector.kagenti-system.svc.cluster.local:8335",
88+
":path", "/v1/traces",
89+
)),
90+
},
91+
}
92+
93+
_ = srv.Process(stream)
94+
95+
if len(stream.responses) != 1 {
96+
t.Fatalf("expected 1 response, got %d", len(stream.responses))
97+
}
98+
rh := stream.responses[0].GetRequestHeaders()
99+
if rh == nil {
100+
t.Fatal("expected RequestHeaders pass-through response")
101+
}
102+
if rh.Response != nil && rh.Response.HeaderMutation != nil &&
103+
len(rh.Response.HeaderMutation.SetHeaders) > 0 {
104+
t.Error("skipped host must not have header mutations (pipeline did not run)")
105+
}
106+
if mp.calls.Load() != 0 {
107+
t.Errorf("pipeline ran %d times; want 0 — SkipHosts must short-circuit before pipeline.Run", mp.calls.Load())
108+
}
109+
if sessions := store.ListSessions(); len(sessions) != 0 {
110+
t.Errorf("%d session(s) recorded; want 0 — SkipHosts must skip recording entirely", len(sessions))
111+
}
112+
}
113+
114+
// TestExtProc_SkipHosts_NonMatchingRunsPipeline is the regression guard:
115+
// with a SkipHosts list set, hosts that don't match must still run the
116+
// pipeline and have their Invocation recorded. Without this pairing,
117+
// the bypass test above could pass trivially with a globally disabled
118+
// pipeline.
119+
func TestExtProc_SkipHosts_NonMatchingRunsPipeline(t *testing.T) {
120+
store := session.New(5*time.Minute, 100, 0)
121+
defer store.Close()
122+
123+
skip, err := skiphost.New([]string{"otel-collector*"})
124+
if err != nil {
125+
t.Fatalf("skiphost.New: %v", err)
126+
}
127+
128+
srv, mp := newSkipServer(t, store, skip)
129+
130+
stream := &mockStream{
131+
ctx: context.Background(),
132+
requests: []*extprocv3.ProcessingRequest{
133+
outboundRequest(makeHeaders(
134+
":authority", "github-tool-mcp:8000",
135+
":path", "/mcp",
136+
)),
137+
},
138+
}
139+
140+
_ = srv.Process(stream)
141+
142+
if mp.calls.Load() != 1 {
143+
t.Errorf("pipeline ran %d times; want 1 — host did not match skip list", mp.calls.Load())
144+
}
145+
if sessions := store.ListSessions(); len(sessions) != 1 {
146+
t.Errorf("session count = %d; want 1 — Invocation should drive recording for non-skipped hosts", len(sessions))
147+
}
148+
}
149+
150+
// TestExtProc_SkipHosts_NilMatcherPreservesBehavior asserts the
151+
// upgrade-safety contract: a Server without SkipHosts (nil Matcher)
152+
// behaves identically to today's code. Pipeline runs, sessions record.
153+
func TestExtProc_SkipHosts_NilMatcherPreservesBehavior(t *testing.T) {
154+
store := session.New(5*time.Minute, 100, 0)
155+
defer store.Close()
156+
157+
srv, mp := newSkipServer(t, store, nil)
158+
159+
stream := &mockStream{
160+
ctx: context.Background(),
161+
requests: []*extprocv3.ProcessingRequest{
162+
outboundRequest(makeHeaders(
163+
":authority", "any-service",
164+
":path", "/",
165+
)),
166+
},
167+
}
168+
169+
_ = srv.Process(stream)
170+
171+
if mp.calls.Load() != 1 {
172+
t.Errorf("nil SkipHosts: pipeline ran %d times, want 1", mp.calls.Load())
173+
}
174+
}

0 commit comments

Comments
 (0)