Skip to content

feat(session): implement the monotonic session boundary the spec describes - #629

Merged
imran-siddique merged 2 commits into
mainfrom
fix/session-boundary-p0006
Sep 11, 2026
Merged

feat(session): implement the monotonic session boundary the spec describes#629
imran-siddique merged 2 commits into
mainfrom
fix/session-boundary-p0006

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

Found while reviewing the Monotonic Session Sensitivity State patent draft against the runtime. Everything the specification describes about the monotonic ratchet and the session boundary a credentialed reset draws is now implemented, so the two can be read against each other.

Supersedes #628, which was cut from a branch that has since been squash-merged. Same first commit, cherry-picked onto current main.

1. A response arriving during a reset raised the successor

The per-session mutation lock serialised the reset and the response elevation but never ordered them, so whichever coroutine acquired it second won. A response in flight when the reset landed was applied to the successor, which had just been initialised to public, and recorded its pre-reset call_id as the call that raised it.

guard=False  successor max_sensitivity=confidential applied=True
guard=True   successor max_sensitivity=public       applied=False

update_from_inspection() now takes the reset_count observed at call entry and drops a response whose generation no longer matches, logging SESSION_RESET_RACE. The discriminator is reset_count and not session_id, because upgrade_attestation() rotates the identifier while deliberately continuing the same session, so a call in flight across an attestation upgrade must still apply. Both cases are tested.

The previous concurrency test asserted only that max_sensitivity remained a member of SENSITIVITY_ORDER, which every value satisfies.

2. The reset route accepted the tool-invocation token

POST /sessions/{id}/reset is not reachable as an MCP tool, but it sat behind the same single CMCP_BEARER_TOKEN as POST /mcp, so an agent host holding only its own tool-invocation credential could clear accumulated session sensitivity.

The operator interface (session reset, catalog exception) now takes CMCP_OPERATOR_TOKEN, which must differ from CMCP_BEARER_TOKEN and is required outside CMCP_DEV_MODE=1 (OPERATOR_TOKEN_REQUIRED). Unset, those routes still fall back to the bearer token, so an existing single-token deployment keeps working until it sets the new variable.

Deployment note: set CMCP_OPERATOR_TOKEN in production before this ships, or startup aborts.

3. The reset audit entry did not identify the boundary

It recorded the sensitivity transition and nothing else. detail now carries the closed session identifier, the successor identifier, the resulting reset counter, and which credential was verified. detail is inside _canonical_body(), so those fields are covered by the entry hash.

4. The chain attributed post-reset entries to the closed session

AuditChain stamps its construction-time session_id, so every entry after a reset carried the closed session's identifier and the successor's appeared nowhere. rotate_session_id() moves attribution after the boundary entry is written, so the reset entry belongs to the session that reached the recorded value.

5. The accumulated value was neither persistent nor shared

Two gaps with one answer, in the new session/store.py.

It did not survive a restart. A session that had reached hipaa_phi came back at public while the session identifier the agent host holds was still live, so enforcement resumed from the minimum level.

It was not shared. Where several instances serve one agent session, each held its own value, so the ratchet held per instance rather than per session. An agent that read sensitive data through one instance and egressed through another was evaluated by an instance that never saw the read.

SqliteSessionStateStore serialises the read-modify-write with BEGIN IMMEDIATE, which takes SQLite's RESERVED lock. Two processes cannot hold it at once, so the critical section spans instances on a shared volume. An asyncio.Lock cannot do this, being invisible to every other instance, and test_the_write_lock_serialises_across_processes proves the difference against a real second process holding the lock. The audit chain already takes its durability from SQLite in WAL mode, so this adds no infrastructure.

apply_inspection and apply_reset are now the write path: each holds the exclusive section, reads the shared value back, folds the response in and writes it, so the greater-of comparison is made against what every instance shares. The gateway hydrates at call entry, before the pre-call policy evaluation reads the value and before the call's generation is captured, so an instance joining a session another instance opened does not permit what the session already forbids.

A reset also advances the closed session's generation in the store while keeping the value it reached. The successor is written under its own identifier, so without that an instance still holding the old identifier would read a generation matching the one it captured and raise a session already closed. That case had to be found by test, not by reading.

Only what must be shared is stored. Injection events, drift lists and the kill switch stay per instance, because copying them would present one instance's observations as another's.

session_state_path is unset by default and the in-process path is byte-for-byte the previous behaviour.

Testing

1681 passing, 19 new. Ruff and mypy clean on src/.

Two failures are pre-existing on main, confirmed by stashing: test_release_distribution_smoke (runtime 0.5.0 against metadata 0.4.0) and test_tpm_chained_verify (assertion text drift). Both passed in CI on #628, so they look local to this machine.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CDzMWcn12dszchgrKfXzZY

imran-siddique and others added 2 commits September 10, 2026 16:36
Found while reviewing the Monotonic Session Sensitivity State draft against
the runtime. A reset is supposed to close one session and open a successor at
the minimum level. Four things let the boundary leak.

A response arriving during a reset raised the successor. The mutation lock
serialised the reset and the response elevation but did not order them, so
whichever coroutine acquired it second won: a response in flight when the reset
landed was applied to the successor and recorded its pre-reset call_id as the
raiser. update_from_inspection() now takes the reset_count observed at call
entry and drops a response whose generation no longer matches. The
discriminator is reset_count and not session_id because upgrade_attestation()
rotates the identifier while deliberately continuing the same session, so a
call in flight across an attestation upgrade must still apply. The previous
concurrency test asserted only that max_sensitivity stayed a member of
SENSITIVITY_ORDER, which every value satisfies.

The reset route accepted the tool-invocation token. POST
/sessions/{id}/reset is not reachable as an MCP tool, but it sat behind the
same CMCP_BEARER_TOKEN as POST /mcp, so an agent host holding its own
credential could clear accumulated sensitivity. The operator interface now
takes CMCP_OPERATOR_TOKEN, required outside dev mode and refused if it equals
the bearer token. Unset, those routes still fall back, so existing deployments
keep working until they set it.

The reset audit entry recorded the sensitivity transition and nothing that
identified the boundary. detail now carries the closed session id, the
successor id, the resulting reset counter and which credential was verified.
detail is inside the canonical body, so those fields are hash-covered.

The chain attributed every entry after a reset to the closed session, and the
successor's identifier appeared nowhere in it. rotate_session_id() moves
attribution after the boundary entry is written.

The closed session's final value is now preserved as a distinct
ClosedSessionRecord rather than overwritten, and the reset response returns it.

Nine tests added. Two suite failures are pre-existing on the base:
test_release_distribution_smoke (version 0.5.0 vs metadata 0.4.0) and
test_tpm_chained_verify (assertion text drift).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDzMWcn12dszchgrKfXzZY
…y value

The accumulated session-sensitivity value lived in the gateway process, which
left two gaps in what the ratchet actually guarantees.

It did not survive a restart. A session that had reached hipaa_phi came back at
public while the session identifier the agent host holds was still live, so the
sensitivity accumulated over the whole session was gone and enforcement resumed
from the minimum level.

It was not shared. Where several instances serve one agent session, each held
its own value, so the ratchet held per instance rather than per session. An
agent that read sensitive data through one instance and egressed through another
was evaluated by an instance that never saw the read.

session/store.py holds both answers behind one protocol.
InMemorySessionStateStore is the default and is the previous behaviour exactly.
SqliteSessionStateStore serialises the read-modify-write with BEGIN IMMEDIATE,
which takes SQLite's RESERVED lock: two processes cannot hold it at once, so the
critical section spans instances on a shared volume. An asyncio.Lock cannot do
this, being invisible to every other instance, and a test proves the difference
against a real second process holding the lock. The audit chain already takes
its durability from SQLite in WAL mode, so turning this on adds no
infrastructure.

apply_inspection and apply_reset are now the write path. Each holds the store's
exclusive section, reads the shared value back, folds the response into it and
writes it, so the greater-of comparison is made against what every instance
shares rather than against one instance's copy. The gateway hydrates at call
entry, before the pre-call policy evaluation reads the value and before the
generation the call belongs to is captured, so an instance joining a session
another instance opened does not permit what the session already forbids.

A reset also advances the closed session's generation in the store while keeping
the value it reached. The successor is written under its own identifier, so
without that an instance still holding the old identifier would read a
generation matching the one it captured and raise a session already closed.

Only the value that must be shared is stored: injection events, drift lists and
the kill switch stay per instance, because copying them would present one
instance's observations as another's.

session_state_path is unset by default. 10 tests added, 1681 passing. The two
suite failures are pre-existing on the base: test_release_distribution_smoke
(runtime 0.5.0 against metadata 0.4.0) and test_tpm_chained_verify (assertion
text drift).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDzMWcn12dszchgrKfXzZY
@imran-siddique
imran-siddique merged commit f028882 into main Sep 11, 2026
17 checks passed
@imran-siddique
imran-siddique deleted the fix/session-boundary-p0006 branch September 11, 2026 00:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants