Skip to content

Commit 86724b1

Browse files
committed
chore: merge main and resolve merge conflicts
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
2 parents 634f6d6 + 4695245 commit 86724b1

33 files changed

Lines changed: 2290 additions & 312 deletions

File tree

.github/workflows/pr-verifier.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ permissions: {}
88

99
jobs:
1010
verify-pr-title:
11+
permissions:
12+
pull-requests: read
1113
uses: kagenti/.github/.github/workflows/pr-verifier-required.yml@4e535f2436d167295d39d488ce5c44b5a2d49792

CLAUDE.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ kagenti-extensions/
4444
│ │ ├── main.go
4545
│ │ ├── Dockerfile # proxy-sidecar lite combined image
4646
│ │ └── entrypoint.sh
47-
│ ├── proxy-init/ # iptables init container (envoy-sidecar mode only)
47+
│ ├── proxy-init/ # iptables init container (envoy-sidecar + proxy-sidecar enforce-redirect modes)
4848
│ │ ├── init-iptables.sh
4949
│ │ ├── Dockerfile.init
5050
│ │ ├── Makefile
@@ -76,7 +76,7 @@ kagenti-extensions/
7676

7777
**Common:**
7878
- `authlib/` — shared auth library (JWT validation, token exchange, caching, routing, all listener implementations, all plugins).
79-
- `proxy-init/init-iptables.sh` — traffic interception setup (Istio ambient mesh compatible). Used by envoy-sidecar mode only.
79+
- `proxy-init/init-iptables.sh` — traffic interception setup (Istio ambient mesh compatible). Used by envoy-sidecar mode (`redirect`) and by proxy-sidecar mode's `enforce-redirect` egress guard.
8080
- `proxy-init/Dockerfile.init` — proxy-init container image.
8181

8282
**Ports (envoy-sidecar):** 15123 (outbound), 15124 (inbound), 9090 (ext-proc), 9901 (admin)
@@ -149,13 +149,20 @@ Three mode-specific binaries, one Dockerfile per binary:
149149

150150
### PR Title Convention
151151

152-
PRs must follow **conventional commits** format:
152+
PR titles must follow the format:
153153

154154
```
155-
<type>: <Subject starting with uppercase>
155+
<Prefix>: <Subject starting with uppercase>
156156
```
157157

158-
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
158+
The CI title check (`deepakputhraya/action-pr-title`) is **case-sensitive** and
159+
requires a **capitalized** prefix from this set: `Build`, `Chore`, `CI`, `Docs`,
160+
`Feat`, `Fix`, `Perf`, `Refactor`, `Revert`, `Style`, `Test`, `Feature`,
161+
`Bug fix`, `Proposal`, `Breaking change`, `Other`, `Other/Misc`.
162+
163+
Note: commit *message* examples elsewhere in this doc use lowercase
164+
(`feat:` / `fix:`) — that is fine for commits, but the PR *title* check rejects
165+
lowercase prefixes. Use `Fix:` / `Feat:` / `Docs:` in PR titles.
159166

160167
## Container Images
161168

@@ -168,7 +175,7 @@ All images are pushed to `ghcr.io/kagenti/kagenti-extensions/` from
168175
| `authbridge-envoy` | `authbridge/cmd/authbridge-envoy/Dockerfile` | envoy-sidecar combined image: Envoy + authbridge-envoy (ext_proc, full plugin set) + spiffe-helper |
169176
| `authbridge-lite` | `authbridge/cmd/authbridge-lite/Dockerfile` | proxy-sidecar lite combined image: authbridge-lite (auth gates only, parsers dropped) + spiffe-helper. Same listener layout as `authbridge`; not yet referenced by the operator's default config |
170177
| `authbridge-cpex` | `authbridge/cmd/authbridge-cpex/Dockerfile` | proxy-sidecar build with the CPEX plugin: authbridge-proxy built with `-tags cpex`, links `libcpex_ffi.a` from a pinned CPEX release (CGO_ENABLED=1). Routes hooks through the CPEX framework (APL DSL + named CPEX policy plugins). FFI ABI version is read from `authbridge/cmd/authbridge-cpex/CPEX_FFI_VERSION` |
171-
| `proxy-init` | `authbridge/proxy-init/Dockerfile.init` | Alpine + iptables init container (envoy-sidecar mode only) |
178+
| `proxy-init` | `authbridge/proxy-init/Dockerfile.init` | Alpine + iptables init container (envoy-sidecar + proxy-sidecar enforce-redirect modes) |
172179

173180
In all three combined images, `spiffe-helper` is started conditionally
174181
based on the `SPIRE_ENABLED` env var (set by the operator when SPIRE

authbridge/CLAUDE.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ authbridge/
7474
│ ├── CPEX_FFI_VERSION # pinned CPEX FFI ABI version (build-arg source of truth)
7575
│ └── entrypoint.sh
7676
77-
├── proxy-init/ # iptables init container (envoy-sidecar mode only)
77+
├── proxy-init/ # iptables init container (envoy-sidecar + proxy-sidecar enforce-redirect modes)
7878
│ ├── init-iptables.sh # iptables setup script
7979
│ ├── Dockerfile.init # proxy-init container image
8080
│ ├── Makefile # docker-build-init + load-image targets
@@ -362,7 +362,7 @@ without needing a CR.
362362
### Build images locally
363363

364364
```bash
365-
# Build the proxy-init iptables init container (envoy-sidecar mode only)
365+
# Build the proxy-init iptables init container (envoy-sidecar + proxy-sidecar enforce-redirect modes)
366366
cd authbridge/proxy-init
367367
make docker-build-init
368368
make load-image # Uses KIND_CLUSTER_NAME env var (default: kagenti)
@@ -591,6 +591,19 @@ See [`docs/framework-architecture.md`](docs/framework-architecture.md#9-config-h
591591

592592
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.
593593

594+
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:
595+
596+
- **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:
597+
```yaml
598+
listener:
599+
skip_hosts:
600+
- "otel-collector.*.svc.cluster.local"
601+
- "*.metrics.local"
602+
```
603+
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.
604+
605+
- **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.
606+
594607
## DCO Sign-Off (Mandatory)
595608

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

authbridge/authlib/bypass/matcher.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,18 @@ type Matcher struct {
2222
// NewMatcher creates a Matcher from the given patterns.
2323
// Returns an error if any pattern has invalid path.Match syntax.
2424
func NewMatcher(patterns []string) (*Matcher, error) {
25-
for _, p := range patterns {
26-
if _, err := path.Match(p, "/"); err != nil {
25+
clean := make([]string, len(patterns))
26+
for i, p := range patterns {
27+
trimmed := strings.TrimSpace(p)
28+
if _, err := path.Match(trimmed, "/"); err != nil {
2729
return nil, fmt.Errorf("invalid bypass pattern %q: %w", p, err)
2830
}
31+
if trimmed == "" || trimmed == "*" || trimmed == "/*" {
32+
return nil, fmt.Errorf("bypass pattern %q is too broad; use specific path globs", p)
33+
}
34+
clean[i] = trimmed
2935
}
30-
return &Matcher{patterns: patterns}, nil
36+
return &Matcher{patterns: clean}, nil
3137
}
3238

3339
// Match checks if the given request path matches any bypass pattern.

authbridge/authlib/bypass/matcher_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,40 @@ func TestMatch_CustomPatterns(t *testing.T) {
8383
t.Error("expected /private/data to not match")
8484
}
8585
}
86+
87+
func TestNewMatcher_RejectsFootgunPatterns(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
pattern string
91+
}{
92+
{"star", "*"},
93+
{"slash-star", "/*"},
94+
{"empty", ""},
95+
{"whitespace-only", " "},
96+
}
97+
for _, tt := range tests {
98+
t.Run(tt.name, func(t *testing.T) {
99+
_, err := NewMatcher([]string{tt.pattern})
100+
if err == nil {
101+
t.Errorf("NewMatcher(%q) should return error for match-all pattern", tt.pattern)
102+
}
103+
})
104+
}
105+
}
106+
107+
func TestNewMatcher_AllowsSpecificStar(t *testing.T) {
108+
_, err := NewMatcher([]string{"/.well-known/*"})
109+
if err != nil {
110+
t.Fatalf("specific star pattern should be allowed: %v", err)
111+
}
112+
}
113+
114+
func TestNewMatcher_TrimsWhitespace(t *testing.T) {
115+
m, err := NewMatcher([]string{" /healthz "})
116+
if err != nil {
117+
t.Fatalf("whitespace-padded pattern should be accepted: %v", err)
118+
}
119+
if !m.Match("/healthz") {
120+
t.Error("trimmed pattern should match /healthz")
121+
}
122+
}

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: 35 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.
@@ -161,6 +171,7 @@ func (s *Server) handleInbound(stream extprocv3.ExternalProcessor_ProcessServer,
161171
action := s.InboundPipeline.Run(ctx, pctx)
162172
if action.Type == pipeline.Reject {
163173
s.recordInboundReject(pctx, action)
174+
s.InboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
164175
return rejectFromAction(action), nil
165176
}
166177

@@ -188,6 +199,7 @@ func (s *Server) handleInboundBody(stream extprocv3.ExternalProcessor_ProcessSer
188199
action := s.InboundPipeline.Run(ctx, pctx)
189200
if action.Type == pipeline.Reject {
190201
s.recordInboundReject(pctx, action)
202+
s.InboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
191203
return rejectFromAction(action), nil
192204
}
193205

@@ -467,6 +479,15 @@ func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer
467479
pctx.Host = getHeader(headers, "host")
468480
}
469481

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+
470491
if s.Sessions != nil {
471492
if aid := s.Sessions.ActiveSession(); aid != "" {
472493
pctx.Session = s.Sessions.View(aid)
@@ -477,6 +498,7 @@ func (s *Server) handleOutbound(stream extprocv3.ExternalProcessor_ProcessServer
477498
action := s.OutboundPipeline.Run(ctx, pctx)
478499
if action.Type == pipeline.Reject {
479500
s.recordOutboundReject(pctx, action)
501+
s.OutboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
480502
return rejectFromAction(action), nil
481503
}
482504

@@ -506,6 +528,18 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe
506528
pctx.Host = getHeader(headers, "host")
507529
}
508530

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+
509543
if s.Sessions != nil {
510544
if aid := s.Sessions.ActiveSession(); aid != "" {
511545
pctx.Session = s.Sessions.View(aid)
@@ -516,6 +550,7 @@ func (s *Server) handleOutboundBody(stream extprocv3.ExternalProcessor_ProcessSe
516550
action := s.OutboundPipeline.Run(ctx, pctx)
517551
if action.Type == pipeline.Reject {
518552
s.recordOutboundReject(pctx, action)
553+
s.OutboundPipeline.RunFinish(ctx, pctx, pipeline.OutcomeFromContext(pctx))
519554
return rejectFromAction(action), nil
520555
}
521556

0 commit comments

Comments
 (0)