Repository navigation
feature: give plugins the same per-session identity that recording gets #984
Description
Feature Description
#983 made event recording resolve its session bucket from the client's session header
(X-Claude-Code-Session-Id), so concurrent coding-agent sessions are told apart in abctl.
It did not touch the other place a session identity is established: pctx.Session, which is
what plugins see.
pctx.Session is still hydrated from Store.ActiveSession() — the global
"most-recently-updated" id — at two forward-proxy sites (request path and CONNECT;
transparent.go is correctly excluded, an opaque connection has no request to read an id
from). So on a laptop with two Claude Code windows open, telemetry attributes correctly while
every plugin still sees whichever session spoke last.
This is a plugin-correctness issue, not a security one. For the local case the user owns
the whole environment and is expected to run several sessions of one agent, or several
different agents, at once. Differentiating messages and attributing them to the right session
is the whole requirement. The open question is only that plugins — the ones we have and the
ones we add — get the same correct identity that recording now gets.
Who consumes it today
| Plugin | Reads | Effect of the shared identity |
|---|---|---|
sessionbudget |
Session.ID (plugin.go:782) → Redis session-budget:<id> |
budget enforcement charges concurrent sessions to whichever spoke last |
contextguru |
Session.ID (plugin.go:340) |
per-session compaction state is shared across sessions |
sparc |
Session.ID (collect.go:115), Session.InferenceRequests() (collect.go:23) |
correlates against every session's inference, not its own |
ibac |
Session.LastIntent() (plugin.go:464), nil-check at :439 |
see the compatibility hazard below |
The full plugin-facing contract is pipeline.SessionView{ID, Events} plus derived accessors
(Intents(), ToolCalls(), LastIntent(), InferenceRequests()).
Compatibility hazard to respect
ibac policies against LastIntent(), which returns the most recent inbound A2A request
event in the view, and can DenyStatus(403, "ibac.no_session") when there is no session at
all. A header-resolved coding-agent bucket contains only outbound events — no inbound A2A
intent — so hydrating from a client header unconditionally could strip IBAC of the intent it
guards, or harden into a 403 path depending on NoIntentPolicy.
The fix for that is already the shape #983 established: reuse the same resolution order,
header → ActiveSession() → default. In-cluster A2A agents do not send a Claude Code
header, so they fall through to ActiveSession() and IBAC behaves exactly as today. Locally
the header is present, so the coding-agent plugins key per session. The change is a no-op
precisely where the A2A correlation is the trustworthy signal.
Two consequences worth noting as improvements: sparc's InferenceRequests() starts seeing
only its own session's inference rather than everyone's, and per-session views get smaller
than one shared default view holding all traffic.
Proposed Solution
- Hydrate
pctx.Sessionusing the same resolution order event recording uses, so the two
cannot disagree within one request - Cover both proxy paths (request, CONNECT); leave
transparent.goonActiveSession() - Verify
ibacis unaffected where no client header is present, including the
NoIntentPolicybranches - Test that two concurrent sessions get separate
sessionbudgetcounters - Document the identity contract on
Context.Session/SessionView.ID— what the id
means, how it is resolved, and that it may be client-asserted — so a future plugin is
correct by default instead of by accident
On the trust question
Store.View() on a client-supplied id lets a caller read another session's view into its own
pipeline context, where plugins make policy decisions on it. That is a wider question than
naming a write bucket, which is all session.IDFromHeaders analyses.
Scoped out for the local case by decision: the user owns the environment, so there is no
adversary to defend against and observability is the goal. It stays relevant for a shared or
standalone forward proxy serving several workloads, where session.id_headers: [] remains the
opt-out. Anyone extending this to a multi-tenant deployment should revisit it rather than
inherit this decision.
Additional Context
Not a regression: pre-existing behavior that #983 does not change, and the boundary is
commented at both hydration sites. Raised in review of #983 by @esnible.
Packaging bounds the enforcement exposure today: sessionbudget and contextguru are in no
build profile (scripts/profile-tags/profiles.go) and link only when a caller asks; sparc
and ibac ship in the full and cpex profiles, neither of which is the laptop build.
Follow-up to #983. Related to #949.
Decision recorded: client-asserted ids as plugin input
Raised in review of #989: the comment that demanded this be settled was being deleted by
that diff, which would have relocated the question rather than answered it. Recording the
answer here and on resolvePluginSessionID.
What is now true. Plugins receive the session identity resolved from a client header
where one is configured, and SessionConfig.SessionIDHeaders() defaults to the Claude Code
header when unset — so this is on by default in every deployment, not only on a laptop.
A client can therefore name which session's state it is handed.
Why that is sharper than it sounds. Context.Session is documented as an observability
and correlation key, not an authorization subject. IBAC is the plugin that tests that
assumption: it judges an outbound call against Session.LastIntent() and can deny on it. So
where header bucketing is enabled, a client naming another session's id can have its action
judged against that session's recorded intent, or choose which sessionbudget bucket it
spends from. Before this change that required winning an ActiveSession() race; naming a
header is deterministic. Session ids are also enumerable through the session API, which
documents itself as unauthenticated.
Rotation-based budget evasion is pre-existing and unchanged. Targeting is what is newly
reachable.
Decision. Accepted for the case this is built for — a laptop, where the user owns every
session and the goal is telling their own concurrent sessions apart. Explicitly not
accepted where session attribution is a trust boundary: a shared or standalone forward
proxy, or any deployment running an authorization plugin on session state. Those set
session.id_headers: [], which returns plugin identity to ActiveSession() only.
Open alternative, not dismissed. Narrow what plugins receive for a client-asserted id:
give the ID (which sessionbudget, contextguru and sparc's keying need) but not the
recorded events, so no client can adopt another session's intent. Cost is sparc's
InferenceRequests() correlation for header-identified sessions — near zero in practice,
since sparc ships only in full/envoy/cpex where sessions are ActiveSession()-derived
anyway. Worth doing if header bucketing is ever wanted in a shared deployment.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
docs: mark the plugin-versus-recording session identity boundary
[-]feature: hydrate pctx.Session from the client session id, not ActiveSession()[/-][+]feature: give plugins the same per-session identity that recording gets[/+]fix: hold the resolved session identity where no plugin can rewrite it
fix: apply the one-identity rule on the transparent path too
test: remove the data race in the transparent-path identity test
feat: give plugins the same per-session identity that recording gets