Skip to content

feature: give plugins the same per-session identity that recording gets #984

Closed

Description

@huang195

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.Session using the same resolution order event recording uses, so the two
    cannot disagree within one request
  • Cover both proxy paths (request, CONNECT); leave transparent.go on ActiveSession()
  • Verify ibac is unaffected where no client header is present, including the
    NoIntentPolicy branches
  • Test that two concurrent sessions get separate sessionbudget counters
  • 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.

Activity

  1. changed the title [-]feature: hydrate pctx.Session from the client session id, not ActiveSession()[/-] [+]feature: give plugins the same per-session identity that recording gets[/+] on Sep 14, 2026
  2. added a commit that references this issue on Sep 14, 2026
    c57785b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions