Skip to content

Commit 2eb3eb1

Browse files
committed
Fix(abctl): address CodeRabbit review — tunnel marker, raw method, footer text
Three review findings on PR #527: - (Major) Don't infer CONNECT tunnels from host/extension shape. With unconditional recording, an ordinary unparsed outbound request can look like a tunnel-open (no protocol ext + host:port) and get wrongly folded with a following same-host request. Add an explicit Tunnel marker to SessionEvent, set by recordTunnelOpened, and key abctl's isTunnelOpen on it instead of the heuristic. (Drops the now-unused hasPort helper.) - (Minor) eventMethod truncates to 22 chars for display but was used for pairing and search — long names sharing a 22-char prefix mis-paired, and truncated suffixes weren't searchable. Add eventMethodValue (raw) for logic; keep eventMethod (truncated) for rendering only. - (Minor) The `s` footer hint said "hide skips" but the toggle also hides passthrough/no-plugin messages — now "[s] hide passthru/skip". Tests: SessionEvent JSON round-trip covers Tunnel; recordTunnelOpened sets the marker; abctl tunnel tests set Tunnel explicitly. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
1 parent 7b12ddf commit 2eb3eb1

7 files changed

Lines changed: 71 additions & 38 deletions

File tree

authbridge/authlib/listener/forwardproxy/transparent.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ func (s *Server) recordTunnelOpened(pctx *pipeline.Context) {
179179
Plugins: plugins,
180180
Identity: pipeline.SnapshotIdentity(pctx),
181181
Host: pctx.Host,
182+
// Explicit opaque-tunnel marker so abctl can fold this CONNECT into
183+
// the decrypted inner request without inferring "tunnel" from shape.
184+
Tunnel: true,
182185
}
183186
// Always record the tunnel-open so passthrough/non-bridged tunnels (no
184187
// plugin activity) are still visible. For a TLS-bridged call abctl folds

authbridge/authlib/listener/forwardproxy/transparent_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,33 @@ import (
88

99
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
1010
"github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/plugintesting"
11+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/session"
1112
)
1213

14+
// TestRecordTunnelOpened_SetsTunnelMarker locks the explicit producer marker:
15+
// a CONNECT / transparent-redirect tunnel-open is recorded with Tunnel=true so
16+
// consumers (abctl) fold it into the decrypted inner request without inferring
17+
// "tunnel" from host/extension shape.
18+
func TestRecordTunnelOpened_SetsTunnelMarker(t *testing.T) {
19+
store := session.New(5*time.Minute, 100, 0)
20+
defer store.Close()
21+
s := &Server{Sessions: store}
22+
23+
s.recordTunnelOpened(&pipeline.Context{Direction: pipeline.Outbound, Host: "example.com:443"})
24+
25+
v := store.View(session.DefaultSessionID)
26+
if v == nil || len(v.Events) != 1 {
27+
t.Fatalf("expected 1 tunnel-open event, got %+v", v)
28+
}
29+
ev := v.Events[0]
30+
if !ev.Tunnel {
31+
t.Error("tunnel-open event must have Tunnel=true")
32+
}
33+
if ev.Direction != pipeline.Outbound || ev.Phase != pipeline.SessionRequest {
34+
t.Errorf("tunnel-open = %v/%v, want Outbound/SessionRequest", ev.Direction, ev.Phase)
35+
}
36+
}
37+
1338
// HandleTransparentConn gates then blind-tunnels: with an allow-all pipeline it
1439
// must dial the recovered destination and copy bytes both ways, emitting no
1540
// proxy-protocol bytes of its own (the agent thinks it's talking to dst).

authbridge/authlib/pipeline/session.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ type SessionEvent struct {
137137
// and for events recorded before the TLS handshake completed.
138138
// Populated by the listener layer; plugins do not write to it.
139139
TLS *EventTLS
140+
141+
// Tunnel marks an opaque CONNECT / transparent-redirect tunnel-open: the
142+
// bytes are not HTTP, so there's no protocol parse. Set only by
143+
// recordTunnelOpened. Consumers (abctl) use it to fold the tunnel into the
144+
// decrypted inner request a TLS bridge produces — an explicit producer
145+
// signal rather than inferring "tunnel" from host/extension shape, which
146+
// an ordinary unparsed request could otherwise mimic.
147+
Tunnel bool
140148
}
141149

142150
// EventTLS describes the TLS state of a connection that produced a
@@ -215,6 +223,7 @@ type sessionEventWire struct {
215223
Host string `json:"host,omitempty"`
216224
DurationMs int64 `json:"durationMs,omitempty"`
217225
TLS *EventTLS `json:"tls,omitempty"`
226+
Tunnel bool `json:"tunnel,omitempty"`
218227
}
219228

220229
func (e SessionEvent) MarshalJSON() ([]byte, error) {
@@ -234,6 +243,7 @@ func (e SessionEvent) MarshalJSON() ([]byte, error) {
234243
Host: e.Host,
235244
DurationMs: e.Duration.Milliseconds(),
236245
TLS: e.TLS,
246+
Tunnel: e.Tunnel,
237247
})
238248
}
239249

@@ -261,6 +271,7 @@ func (e *SessionEvent) UnmarshalJSON(data []byte) error {
261271
Host: w.Host,
262272
Duration: time.Duration(w.DurationMs) * time.Millisecond,
263273
TLS: w.TLS,
274+
Tunnel: w.Tunnel,
264275
}
265276
return nil
266277
}

authbridge/authlib/pipeline/session_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func TestSessionEvent_JSONRoundTrip(t *testing.T) {
7979
Identity: &EventIdentity{Subject: "alice", ClientID: "agent-1"},
8080
StatusCode: 200,
8181
Error: &EventError{Kind: "upstream", Message: "timeout"},
82+
Tunnel: true,
8283
}
8384

8485
first, err := json.Marshal(orig)

authbridge/cmd/abctl/tui/events_pane.go

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -206,14 +206,11 @@ func buildEventRows(events []pipeline.SessionEvent) []eventRow {
206206
}
207207

208208
// isTunnelOpen reports whether e is a CONNECT / transparent-redirect
209-
// tunnel-open: an outbound request event with no protocol extension (the
210-
// bytes are opaque) whose host carries an explicit port. recordTunnelOpened
211-
// is the only producer of such events.
209+
// tunnel-open. It keys on the explicit Tunnel marker the producer
210+
// (recordTunnelOpened) sets — NOT on host/extension shape, which an ordinary
211+
// unparsed outbound request could mimic and get wrongly folded.
212212
func isTunnelOpen(e *pipeline.SessionEvent) bool {
213-
return e.Direction == pipeline.Outbound &&
214-
e.Phase == pipeline.SessionRequest &&
215-
e.A2A == nil && e.MCP == nil && e.Inference == nil &&
216-
hasPort(e.Host)
213+
return e.Tunnel
217214
}
218215

219216
// isBridgedInner reports whether inner is the decrypted request the TLS bridge
@@ -226,12 +223,9 @@ func isTunnelOpen(e *pipeline.SessionEvent) bool {
226223
// Two guards keep unrelated events from folding:
227224
// - inner must be another outbound REQUEST, never a response, so a plain
228225
// request→response exchange isn't mistaken for a bridged pair.
229-
// - inner must NOT itself be a tunnel-open. Two back-to-back passthrough
230-
// CONNECTs to the same host (common with connection pooling) would
231-
// otherwise fold — hiding one real message and mislabeling the other. A
232-
// genuine decrypted inner request is not opaque-with-a-port, so this only
233-
// excludes the rare non-standard-port bridge whose inner had no parser
234-
// match (still shown, just as its own row rather than folded).
226+
// - inner must NOT itself be a tunnel-open (Tunnel marker). Two back-to-back
227+
// passthrough CONNECTs to the same host (common with connection pooling)
228+
// would otherwise fold — hiding one real message and mislabeling the other.
235229
func isBridgedInner(tunnel, inner *pipeline.SessionEvent) bool {
236230
host := hostOnly(inner.Host)
237231
return inner.Direction == pipeline.Outbound &&
@@ -250,12 +244,6 @@ func hostOnly(hostport string) string {
250244
return hostport
251245
}
252246

253-
// hasPort reports whether hostport parses as "host:port".
254-
func hasPort(hostport string) bool {
255-
_, _, err := net.SplitHostPort(hostport)
256-
return err == nil
257-
}
258-
259247
// allInvocations returns every plugin invocation on an event, both
260248
// directions concatenated (inbound first). Returns nil when the event
261249
// carries no Invocations.
@@ -414,18 +402,28 @@ func shortPhase(p pipeline.SessionPhase) string {
414402
return "?"
415403
}
416404

417-
func eventMethod(e pipeline.SessionEvent) string {
405+
// eventMethodValue is the raw, untruncated method/model for an event — the A2A
406+
// method, inference model, or MCP method. Used for logic (pairing, filtering)
407+
// where truncation would conflate distinct names sharing a 22-char prefix or
408+
// hide searchable suffixes.
409+
func eventMethodValue(e pipeline.SessionEvent) string {
418410
switch {
419411
case e.A2A != nil:
420-
return truncStr(e.A2A.Method, 22)
412+
return e.A2A.Method
421413
case e.Inference != nil:
422-
return truncStr(e.Inference.Model, 22)
414+
return e.Inference.Model
423415
case e.MCP != nil:
424-
return truncStr(e.MCP.Method, 22)
416+
return e.MCP.Method
425417
}
426418
return ""
427419
}
428420

421+
// eventMethod is the display form of the method/model — truncated to the
422+
// METHOD column width. Render-only; never compare or search on it.
423+
func eventMethod(e pipeline.SessionEvent) string {
424+
return truncStr(eventMethodValue(e), 22)
425+
}
426+
429427
func statusCell(e pipeline.SessionEvent) string {
430428
if e.StatusCode == 0 {
431429
return ""
@@ -504,7 +502,7 @@ func computeEventPairs(rows []eventRow) (map[*pipeline.SessionEvent]int, map[int
504502
}
505503
if ri.Direction != rj.Direction ||
506504
hostOnly(ri.Host) != hostOnly(rj.Host) ||
507-
eventMethod(*ri) != eventMethod(*rj) {
505+
eventMethodValue(*ri) != eventMethodValue(*rj) {
508506
continue
509507
}
510508
partner[i] = j
@@ -697,7 +695,7 @@ func eventMatchesDeny(e *pipeline.SessionEvent) bool {
697695
// caller identity, and protocol-specific content (A2A parts, MCP error, the
698696
// inference completion / finish reason).
699697
func eventHaystack(e *pipeline.SessionEvent) []string {
700-
hay := []string{e.Host, eventMethod(*e)}
698+
hay := []string{e.Host, eventMethodValue(*e)}
701699
for _, iv := range allInvocations(e) {
702700
hay = append(hay, iv.Plugin, string(iv.Action), iv.Reason, iv.Path)
703701
// Plugin-specific diagnostic context — iterate keys + values so

authbridge/cmd/abctl/tui/events_pane_test.go

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ func TestBuildEventRows_CollapsesBridgedConnect(t *testing.T) {
326326
Direction: pipeline.Outbound,
327327
Phase: pipeline.SessionRequest,
328328
Host: "api.anthropic.com:443",
329+
Tunnel: true,
329330
Invocations: &pipeline.Invocations{
330331
Outbound: []pipeline.Invocation{{Plugin: "jwt-validation", Action: pipeline.ActionSkip}},
331332
},
@@ -380,7 +381,7 @@ func TestBuildEventRows_CollapsesBridgedConnect(t *testing.T) {
380381
// by a different-host request (host-mismatch guard).
381382
func TestBuildEventRows_PassthroughTunnelStandsAlone(t *testing.T) {
382383
connect := func(host string) pipeline.SessionEvent {
383-
return pipeline.SessionEvent{Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: host}
384+
return pipeline.SessionEvent{Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: host, Tunnel: true}
384385
}
385386
cases := []struct {
386387
name string
@@ -429,7 +430,7 @@ func TestBuildEventRows_TunnelInvocationsFoldIntoRow(t *testing.T) {
429430
events := []pipeline.SessionEvent{
430431
// CONNECT tunnel-open — an egress gate explicitly ALLOWED it.
431432
{
432-
Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "api.example.com:443",
433+
Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "api.example.com:443", Tunnel: true,
433434
Invocations: &pipeline.Invocations{Outbound: []pipeline.Invocation{
434435
{Plugin: "egress-policy", Action: pipeline.ActionAllow},
435436
}},
@@ -642,21 +643,15 @@ func TestStatusCell(t *testing.T) {
642643
// TestHostOnly covers port stripping (used by collapse + pairing), including
643644
// the no-port and IPv6 cases.
644645
func TestHostOnly(t *testing.T) {
645-
cases := []struct {
646-
in, want string
647-
port bool
648-
}{
649-
{"example.com:443", "example.com", true},
650-
{"example.com", "example.com", false},
651-
{"[::1]:8443", "::1", true},
646+
cases := []struct{ in, want string }{
647+
{"example.com:443", "example.com"},
648+
{"example.com", "example.com"},
649+
{"[::1]:8443", "::1"},
652650
}
653651
for _, tc := range cases {
654652
if got := hostOnly(tc.in); got != tc.want {
655653
t.Errorf("hostOnly(%q) = %q, want %q", tc.in, got, tc.want)
656654
}
657-
if got := hasPort(tc.in); got != tc.port {
658-
t.Errorf("hasPort(%q) = %v, want %v", tc.in, got, tc.port)
659-
}
660655
}
661656
}
662657

authbridge/cmd/abctl/tui/keys.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ func (m *model) helpView() string {
456456
}
457457
return "[↑↓] nav [↵] drill [tab] pipeline [/] filter [p] pause [q] quit"
458458
case paneEvents:
459-
skipHint := "[s] hide skips"
459+
skipHint := "[s] hide passthru/skip"
460460
if m.hideInactive {
461461
skipHint = "[s] show all"
462462
}

0 commit comments

Comments
 (0)