Skip to content

Commit a500261

Browse files
committed
fix(authbridge): Add listener.skip_hosts to bypass infrastructure traffic
A chatty outbound destination (e.g., an OpenTelemetry collector sidecar that exports dozens of times per agent turn) fills the per-session FIFO event buffer and evicts the inbound A2A user intent that IBAC needs to align tool calls. Once evicted, IBAC's LastIntent() returns nil and the plugin falls through to the no_user_context skip path with sub_reason no_intent -- every tool call after the first is allowed without LLM- judged alignment. Add a listener-level skip list. Hosts matching listener.skip_hosts are forwarded as a transparent proxy: no pipeline run, no session event recorded. The matcher uses the same dot-delimited gobwas/glob semantics as authproxy-routes; ports are stripped before matching. The skip applies symmetrically to both proxy-sidecar (forward + reverse proxy) and envoy-sidecar (ext_proc) deployments, and to both HTTP and CONNECT- tunnel paths in proxy-sidecar mode. Wired through the runtime config; required a switch from struct == to reflect.DeepEqual in the reloader's listener-comparison guard since ListenerConfig now contains a slice. Includes per-listener tests covering: skipped host bypasses pipeline plus recording while still forwarding upstream, non-matching host runs the pipeline normally, and a nil matcher preserves today's behavior. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
1 parent 21663be commit a500261

12 files changed

Lines changed: 772 additions & 88 deletions

File tree

authbridge/CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,15 @@ 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 can evict the IBAC user intent**: The session store is FIFO with a default cap of 100 events per session. If an outbound destination is high-volume (e.g., an OpenTelemetry collector sidecar that exports dozens of times per agent turn), each export becomes a session event and the original inbound A2A user intent rolls out the back of the buffer within seconds. IBAC then sees `LastIntent() == nil` and falls through to the `no_user_context` skip path with `sub_reason: no_intent`, allowing every tool call after the first without LLM-judged alignment. Mitigate by listing the offending hosts under `listener.skip_hosts` in the runtime config — matched destinations bypass the pipeline AND session recording entirely (transparent forward), so they no longer compete with agent-meaningful events for buffer slots. Patterns use the same `.`-delimited glob semantics as `authproxy-routes`; ports are stripped before matching. Example:
583+
```yaml
584+
listener:
585+
skip_hosts:
586+
- "otel-collector.*.svc.cluster.local"
587+
- "*.metrics.local"
588+
```
589+
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.
590+
582591
## DCO Sign-Off (Mandatory)
583592

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

authbridge/authlib/config/config.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,31 @@ 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+
SkipHosts []string `yaml:"skip_hosts" json:"skip_hosts"`
337362
}
338363

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

authbridge/authlib/listener/extproc/server.go

Lines changed: 29 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.
@@ -467,6 +477,15 @@ func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer
467477
pctx.Host = getHeader(headers, "host")
468478
}
469479

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

528+
// SkipHosts short-circuit: see handleOutbound for rationale. The
529+
// body-phase entry point needs the same gate because Envoy may
530+
// deliver the body in a separate ProcessingRequest message even
531+
// when the headers were already passed through — without checking
532+
// here, a skip-listed host whose request carries a body would still
533+
// run the pipeline on the body phase.
534+
if s.SkipHosts.Match(pctx.Host) {
535+
return allowBodyResponse(), nil
536+
}
537+
509538
if s.Sessions != nil {
510539
if aid := s.Sessions.ActiveSession(); aid != "" {
511540
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)