Skip to content

Commit 4773e4d

Browse files
committed
fix(authbridge): Pin most-recent A2A intent against FIFO eviction
The session store evicts the oldest events when a bucket exceeds maxEvents (default 100). This is the right policy in general but catastrophic for one specific event: the inbound A2A request that carries the user's intent, which IBAC's LastIntent() relies on to align outbound tool calls. A chatty agent can fill the buffer with inference + MCP + observability events in seconds, evicting the intent and breaking IBAC for the rest of the turn. listener.skip_hosts is the right primary fix when the offending traffic is identifiable infrastructure. This commit adds a defense-in-depth backstop in the store itself: the eviction policy now pins the most-recent inbound A2A request event so it survives FIFO trimming. Other events evict in chronological order; the protected intent stays at its original timestamp, leaving a visible time gap in the timeline (consumers like abctl see events in the same monotonic order they were appended, just with a gap). Only the most-recent intent is pinned. Older intents from earlier turns evict normally so a multi-turn conversation with huge fan-out cannot starve the buffer with stale intents. The intent predicate matches SessionView.LastIntent exactly, so the eviction policy and the consumer view cannot drift apart. Tests cover: GSM8K reproducer (intent survives 20 chatty events), no-intent regression (FIFO unchanged for non-intent buckets), multi-turn with only most-recent pinned, fast-path when intent is already in the keep window, chronological-order preservation, and the all-intents pathological bucket (maxEvents still respected). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
1 parent a500261 commit 4773e4d

3 files changed

Lines changed: 289 additions & 12 deletions

File tree

authbridge/CLAUDE.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -579,14 +579,18 @@ 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.
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. This means IBAC's `LastIntent()` will return non-nil as long as ANY inbound A2A request landed in the bucket since session start, regardless of how chatty the outbound traffic is. The pin is defense-in-depth; reach for `skip_hosts` first when the offending traffic is identifiable infrastructure.
590594

591595
## DCO Sign-Off (Mandatory)
592596

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package session
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
8+
)
9+
10+
// intent fabricates the same shape SessionView.LastIntent recognizes:
11+
// inbound A2A request event. Tests below assert that this exact shape
12+
// survives FIFO eviction so IBAC's LastIntent never returns nil after
13+
// chatty traffic floods the buffer.
14+
func intent(method string) pipeline.SessionEvent {
15+
return pipeline.SessionEvent{
16+
At: time.Now(),
17+
Direction: pipeline.Inbound,
18+
Phase: pipeline.SessionRequest,
19+
A2A: &pipeline.A2AExtension{Method: method},
20+
}
21+
}
22+
23+
// chatter fabricates an outbound non-intent event. Stand-in for the
24+
// OTel exports / inference calls / tool calls that flooded the bucket
25+
// in the GSM8K reproducer.
26+
func chatter(tag string) pipeline.SessionEvent {
27+
return pipeline.SessionEvent{
28+
At: time.Now(),
29+
Direction: pipeline.Outbound,
30+
Phase: pipeline.SessionRequest,
31+
MCP: &pipeline.MCPExtension{Method: tag},
32+
}
33+
}
34+
35+
// TestStore_PinsIntentAcrossEviction reproduces the GSM8K bug at the
36+
// store level: an inbound A2A intent followed by enough chatty
37+
// outbound traffic to overflow maxEvents. Pre-fix, the intent rolled
38+
// out the back of the FIFO and LastIntent returned nil. Post-fix the
39+
// intent must survive — that's the property IBAC depends on.
40+
func TestStore_PinsIntentAcrossEviction(t *testing.T) {
41+
s := New(5*time.Minute, 5, 0)
42+
43+
s.Append("sess", intent("solve-math"))
44+
for i := 0; i < 20; i++ {
45+
s.Append("sess", chatter("chat"))
46+
}
47+
48+
v := s.View("sess")
49+
if v == nil {
50+
t.Fatal("View returned nil")
51+
}
52+
if len(v.Events) != 5 {
53+
t.Fatalf("Events len = %d, want 5 (capped at maxEvents)", len(v.Events))
54+
}
55+
last := v.LastIntent()
56+
if last == nil {
57+
t.Fatal("LastIntent() = nil; intent must survive eviction even when buried under chatter")
58+
}
59+
if last.A2A == nil || last.A2A.Method != "solve-math" {
60+
t.Errorf("LastIntent A2A.Method = %v, want solve-math", last.A2A)
61+
}
62+
}
63+
64+
// TestStore_NoIntent_FifoEvictionUnchanged is the regression guard:
65+
// a bucket with no intent in it must keep behaving exactly like
66+
// today's FIFO. Pinning logic must not change non-intent buckets.
67+
func TestStore_NoIntent_FifoEvictionUnchanged(t *testing.T) {
68+
s := New(5*time.Minute, 3, 0)
69+
70+
for i := 0; i < 5; i++ {
71+
ev := chatter(string(rune('a' + i)))
72+
s.Append("sess", ev)
73+
}
74+
75+
v := s.View("sess")
76+
if len(v.Events) != 3 {
77+
t.Fatalf("Events len = %d, want 3", len(v.Events))
78+
}
79+
// Oldest two evicted: surviving methods are c, d, e.
80+
want := []string{"c", "d", "e"}
81+
for i, w := range want {
82+
if v.Events[i].MCP.Method != w {
83+
t.Errorf("Events[%d].MCP.Method = %q, want %q", i, v.Events[i].MCP.Method, w)
84+
}
85+
}
86+
}
87+
88+
// TestStore_OnlyMostRecentIntentPinned: a bucket with multiple
89+
// intents (multi-turn conversation) must pin only the LATEST.
90+
// Older intents evict via normal FIFO so pathological traffic
91+
// (many turns + huge fan-out) can't starve the buffer with stale
92+
// intents.
93+
func TestStore_OnlyMostRecentIntentPinned(t *testing.T) {
94+
s := New(5*time.Minute, 3, 0)
95+
96+
s.Append("sess", intent("turn-1"))
97+
s.Append("sess", chatter("c1"))
98+
s.Append("sess", intent("turn-2"))
99+
for i := 0; i < 10; i++ {
100+
s.Append("sess", chatter("flood"))
101+
}
102+
103+
v := s.View("sess")
104+
if len(v.Events) != 3 {
105+
t.Fatalf("Events len = %d, want 3", len(v.Events))
106+
}
107+
last := v.LastIntent()
108+
if last == nil || last.A2A == nil || last.A2A.Method != "turn-2" {
109+
t.Errorf("LastIntent.A2A.Method = %v, want turn-2 (only the latest intent is pinned)", last)
110+
}
111+
// Older intent should be gone (evicted by FIFO with only the
112+
// latest pinned).
113+
for _, e := range v.Events {
114+
if e.A2A != nil && e.A2A.Method == "turn-1" {
115+
t.Errorf("turn-1 intent survived; only the most recent intent should be pinned")
116+
}
117+
}
118+
}
119+
120+
// TestStore_IntentInKeepWindow_TrivialFifo: when the intent is
121+
// already in the keep window (i.e. plain FIFO would preserve it
122+
// anyway), the result is byte-identical to plain FIFO. Catches
123+
// regressions where the pin path mutates or reorders unnecessarily.
124+
func TestStore_IntentInKeepWindow_TrivialFifo(t *testing.T) {
125+
s := New(5*time.Minute, 3, 0)
126+
127+
s.Append("sess", chatter("a"))
128+
s.Append("sess", chatter("b"))
129+
s.Append("sess", intent("recent-intent"))
130+
s.Append("sess", chatter("c"))
131+
s.Append("sess", chatter("d"))
132+
133+
v := s.View("sess")
134+
if len(v.Events) != 3 {
135+
t.Fatalf("Events len = %d, want 3", len(v.Events))
136+
}
137+
// Plain FIFO would preserve [intent, c, d] — and the intent is
138+
// already in the keep window, so the pin path takes the fast
139+
// branch and produces exactly that.
140+
if v.Events[0].A2A == nil || v.Events[0].A2A.Method != "recent-intent" {
141+
t.Errorf("Events[0] = %v, want recent-intent (FIFO order preserved)", v.Events[0])
142+
}
143+
if v.Events[1].MCP == nil || v.Events[1].MCP.Method != "c" {
144+
t.Errorf("Events[1].MCP.Method = %v, want c", v.Events[1].MCP)
145+
}
146+
if v.Events[2].MCP == nil || v.Events[2].MCP.Method != "d" {
147+
t.Errorf("Events[2].MCP.Method = %v, want d", v.Events[2].MCP)
148+
}
149+
}
150+
151+
// TestStore_PinnedIntentChronologicalOrder: with the intent pinned
152+
// from the eviction prefix, the surviving slice must still be
153+
// chronologically ordered (At ascending). Consumers like abctl
154+
// render events in slice order assuming monotonic time; a
155+
// non-monotonic result here would break the timeline UI.
156+
func TestStore_PinnedIntentChronologicalOrder(t *testing.T) {
157+
s := New(5*time.Minute, 3, 0)
158+
159+
// Append intent first so it has the earliest timestamp, then
160+
// flood with chatter. Pin path keeps intent at index 0 and the
161+
// most-recent two chatters at indices 1, 2.
162+
intentEv := intent("first")
163+
intentEv.At = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
164+
s.Append("sess", intentEv)
165+
for i := 0; i < 10; i++ {
166+
ev := chatter("chat")
167+
ev.At = time.Date(2026, 1, 1, 12, 0, i+1, 0, time.UTC)
168+
s.Append("sess", ev)
169+
}
170+
171+
v := s.View("sess")
172+
if len(v.Events) != 3 {
173+
t.Fatalf("Events len = %d, want 3", len(v.Events))
174+
}
175+
for i := 1; i < len(v.Events); i++ {
176+
if !v.Events[i].At.After(v.Events[i-1].At) {
177+
t.Errorf("Events[%d].At (%v) not after Events[%d].At (%v) — pin must preserve chronological order",
178+
i, v.Events[i].At, i-1, v.Events[i-1].At)
179+
}
180+
}
181+
if v.Events[0].A2A == nil || v.Events[0].A2A.Method != "first" {
182+
t.Errorf("Events[0] should be the pinned intent, got %v", v.Events[0])
183+
}
184+
}
185+
186+
// TestStore_AllIntentsBucket: a pathological bucket made entirely
187+
// of intent events must still respect maxEvents. Only the most
188+
// recent intent is "protected" — the rest evict via plain FIFO.
189+
// This bounds the worst case (intents alone cannot starve the buffer).
190+
func TestStore_AllIntentsBucket(t *testing.T) {
191+
s := New(5*time.Minute, 3, 0)
192+
193+
for i := 0; i < 10; i++ {
194+
s.Append("sess", intent(string(rune('a'+i))))
195+
}
196+
197+
v := s.View("sess")
198+
if len(v.Events) != 3 {
199+
t.Fatalf("Events len = %d, want 3 (maxEvents must hold even for all-intent buckets)", len(v.Events))
200+
}
201+
last := v.LastIntent()
202+
if last == nil || last.A2A == nil || last.A2A.Method != "j" {
203+
t.Errorf("LastIntent.Method = %v, want j (most recent intent)", last)
204+
}
205+
}

authbridge/authlib/session/store.go

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,7 @@ func (s *Store) Append(sessionID string, event pipeline.SessionEvent) {
193193
s.publishLocked(event)
194194

195195
if s.maxEvents > 0 && len(sess.Events) > s.maxEvents {
196-
excess := len(sess.Events) - s.maxEvents
197-
trimmed := make([]pipeline.SessionEvent, s.maxEvents)
198-
copy(trimmed, sess.Events[excess:])
199-
sess.Events = trimmed
196+
sess.Events = trimEventsPinIntent(sess.Events, s.maxEvents)
200197
}
201198

202199
// Evict oldest session if cap is exceeded.
@@ -205,6 +202,77 @@ func (s *Store) Append(sessionID string, event pipeline.SessionEvent) {
205202
}
206203
}
207204

205+
// isIntentEvent matches the SessionView.LastIntent predicate: an
206+
// inbound A2A request event. IBAC and any future intent-aware
207+
// guardrail call LastIntent and need the most recent such event to
208+
// survive FIFO eviction. Defined here so the eviction policy and
209+
// the consumer view can never drift apart.
210+
func isIntentEvent(e pipeline.SessionEvent) bool {
211+
return e.Direction == pipeline.Inbound &&
212+
e.Phase == pipeline.SessionRequest &&
213+
e.A2A != nil
214+
}
215+
216+
// trimEventsPinIntent reduces events to len <= maxEvents while
217+
// preserving the most-recent intent event, even when that intent
218+
// sits in the prefix that FIFO would normally evict. All other
219+
// events evict in chronological order; the protected intent stays
220+
// at its original index, leaving a temporal gap between it and the
221+
// first non-evicted event after it. That gap is visible in
222+
// /v1/sessions and abctl as a discontinuity in the timeline, which
223+
// is the right shape: the intent's append time is preserved
224+
// (consumers can correlate against the inbound request's wall-clock
225+
// timestamp) and chronological order across surviving events stays
226+
// monotonic.
227+
//
228+
// Why pin only the MOST-RECENT intent: a session can carry several
229+
// user turns ("solve this", then "now do that") and only the latest
230+
// is what IBAC aligns subsequent tool calls against. Pinning all
231+
// intents would let stale ones pile up under pathological loads
232+
// (slow turns + huge fan-out) and starve the buffer.
233+
//
234+
// Pathological case — the buffer is mostly intents, exceeds
235+
// maxEvents, and only the latest is pinned: older intents evict via
236+
// normal FIFO. There is no scenario where this returns more than
237+
// maxEvents events.
238+
//
239+
// Caller guarantees len(events) > maxEvents and maxEvents > 0;
240+
// otherwise this is a no-op shape (returns events unchanged).
241+
func trimEventsPinIntent(events []pipeline.SessionEvent, maxEvents int) []pipeline.SessionEvent {
242+
if maxEvents <= 0 || len(events) <= maxEvents {
243+
return events
244+
}
245+
excess := len(events) - maxEvents
246+
247+
// Locate the most-recent intent. If it sits in the keep window
248+
// (index >= excess) it survives a plain FIFO trim — fast path.
249+
intentIdx := -1
250+
for i := len(events) - 1; i >= 0; i-- {
251+
if isIntentEvent(events[i]) {
252+
intentIdx = i
253+
break
254+
}
255+
}
256+
if intentIdx == -1 || intentIdx >= excess {
257+
// No protected event in the eviction prefix; FIFO trim.
258+
trimmed := make([]pipeline.SessionEvent, maxEvents)
259+
copy(trimmed, events[excess:])
260+
return trimmed
261+
}
262+
263+
// Protected intent is in the eviction prefix. Build the result
264+
// as: [pinned intent] ++ [last maxEvents-1 non-intent-prefix
265+
// events]. We keep the intent at the front so its original
266+
// timestamp is preserved AND the resulting slice stays
267+
// chronologically ordered (intent.At < kept-tail.At by construction —
268+
// it's the oldest surviving event).
269+
out := make([]pipeline.SessionEvent, 0, maxEvents)
270+
out = append(out, events[intentIdx])
271+
tailStart := len(events) - (maxEvents - 1)
272+
out = append(out, events[tailStart:]...)
273+
return out
274+
}
275+
208276
// View returns a read-only snapshot of the session's events.
209277
// Returns nil if the session doesn't exist or is expired.
210278
func (s *Store) View(sessionID string) *pipeline.SessionView {

0 commit comments

Comments
 (0)