Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- **A response arriving during an operator reset raised the successor session.**
The per-session mutation lock serialised a reset and a 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, which had just
been initialised to `public`, and recorded the pre-reset `call_id` as the call
that raised it. The successor exists to start at the minimum level, so this
carried the closed session's sensitivity across the boundary the reset drew.
`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` rather than
`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` remained 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 single `CMCP_BEARER_TOKEN` as `POST /mcp`, so an agent host holding its
own tool-invocation credential could clear accumulated session sensitivity.
The operator interface (session reset and 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`). Where it is
unset those routes still fall back to the bearer token, so an existing
single-token deployment keeps working until it sets the new variable.

### Added

- **The accumulated session-sensitivity value can now live in a shared,
persistent store** (`session_state_path`). Without one it is held in the
gateway process, so it is lost on restart while the session identifier the
agent host holds is still live, and where several instances serve one session
the ratchet holds per instance rather than per session: an agent that reads
sensitive data through one instance and egresses through another is evaluated
by an instance that never saw the read. `SqliteSessionStateStore` serialises
the read-modify-write with `BEGIN IMMEDIATE`, which takes SQLite's RESERVED
lock and so spans processes; an `asyncio.Lock` cannot, being invisible to every
other instance. A gateway now hydrates the session's stored value at call entry,
before the pre-call policy evaluation reads it. A reset also advances the closed
session's generation in the store, so an instance still holding the old
identifier stops applying responses to it. Unset is the default and preserves
the previous single-instance behaviour exactly.

### Changed

- The reset audit entry now identifies the session boundary rather than only the
sensitivity transition: `detail` carries the closed session identifier, the
successor identifier, the resulting reset counter, and which credential was
verified. `detail` is inside the canonical body, so those fields are covered by
the entry hash.

- **The audit chain no longer attributes post-reset entries to the closed
session.** `AuditChain.rotate_session_id()` moves attribution to the successor
after the boundary entry is written, so the reset entry belongs to the session
that reached the recorded value and later entries belong to the successor.
Previously every entry after a reset carried the closed session's identifier
and the successor's identifier appeared nowhere in the chain.

- A reset now preserves the closed session's final state as a distinct
`ClosedSessionRecord` instead of overwriting it, and
`POST /sessions/{id}/reset` returns `closed_session_max_sensitivity` and
`reset_count`.

## [0.5.0] - 2026-09-05

### Security
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ chain and TRACE Claim; this setting controls whether the session also fails clos
| `policy_bundle_path` | string | `policies/` | Path to the Cedar policy bundle directory. Must contain `.cedar` files and a `manifest.json`. Path traversal (`..` components) is rejected. |
| `catalog_path` | string | `catalog.json` | Path to the JSON tool catalog. Path traversal (`..` components) is rejected. |
| `listen_addr` | string | `0.0.0.0:8443` | Address and port the gateway binds to. Default is `127.0.0.1:8443` in tokenless `CMCP_DEV_MODE=1`, otherwise `0.0.0.0:8443`. Tokenless dev mode requires loopback (e.g., `127.0.0.1:8443`, `localhost:8443`, `[::1]:8443`). Wildcard, LAN, public, and non-loopback hostname binds require `CMCP_BEARER_TOKEN`. |
| `session_state_path` | string or unset | unset | Path to the shared session-state database. Unset keeps the accumulated session-sensitivity value in the gateway process: correct for a single instance, but the value is lost on restart and each instance ratchets separately. Set it to a path on a volume every instance shares to make the ratchet hold per session across instances and survive a restart. Writes are serialised with SQLite `BEGIN IMMEDIATE`, which is a cross-process lock, so no external coordinator is needed. Path traversal (`..` components) is rejected. |
| `max_response_size_bytes` | integer | `2097152` | Maximum tool response size in bytes (2MB). Must be a positive integer. Responses exceeding this limit are rejected before inspection. |
| `policy_reload_interval_seconds` | integer | `0` | Interval in seconds between automatic Cedar bundle reloads. `0` disables automatic reload. Above `0` requires a pinned `CMCP_POLICY_SIGNING_KEY`: **an interval alongside only a pinned `CMCP_POLICY_HASH` aborts startup** (`POLICY_RELOAD_PINNED_HASH`): the reload re-validates against that pinned hash, so a bundle that actually changed would always be rejected and the old policy would stay in force. A hash pins one artifact and so cannot authorise a bundle that changed; a signing key approves any bundle the authority signs, which is what reload needs. Both pins together is the supported production shape. See [Policy Hot-Reload](spec/policy-hot-reload.md). |
| `conformance_profile` | string or unset | unset | A named profile that tightens defaults which stay permissive for developers. Only `aarm` is defined: it requires an Agent Manifest binding, because AARM R6 says every receipt MUST be bound to an agent identity while the developer default leaves binding optional. With it set, the gateway refuses to start unless `agent_manifest.path` and `agent_manifest.trust_anchor_path` are configured (`CONFORMANCE_PROFILE_UNSATISFIED`). An unrecognised name is a config error rather than a profile that enforces nothing. |
Expand All @@ -135,6 +136,7 @@ Environment variables control secrets and mode flags that must not appear in con
|----------|-------------|-----------|
| `CMCP_DEV_MODE=1` | Enables software-only attestation. No hardware TEE required. TRACE Claims will show `partially_verified` status. Required when `provider` is `software-only`. | `attestation.provider` (forces software-only) |
| `CMCP_BEARER_TOKEN` | Optional bearer token for runtime HTTP auth. If set, all requests to the runtime must include `Authorization: Bearer <token>`. If unset, no bearer auth is enforced. This token is required for non-loopback binds. | none |
| `CMCP_OPERATOR_TOKEN` | Credential for the operator interface: `POST /sessions/{id}/reset` and `POST /catalog/exception`. Required outside `CMCP_DEV_MODE=1` (`OPERATOR_TOKEN_REQUIRED`), and must differ from `CMCP_BEARER_TOKEN`. When set, those two routes accept only this token and reject the tool-invocation token; when unset they fall back to `CMCP_BEARER_TOKEN`. A reset lowers accumulated session sensitivity, so an agent host holding only the tool-invocation token cannot clear the state that monotonicity exists to keep. | none |
| `OPAQUE_ATTESTATION_URL` | Enables the OPAQUE Managed Runtime provider. Must be set to the OPAQUE attestation service URL. Required when `provider` is `opaque` or `auto` on OPAQUE infrastructure. | enables `opaque` provider detection |
| `CMCP_POLICY_HASH` | SHA-256 hash of the approved policy bundle. Required in non-dev mode and checked by startup before Agent Manifest binding. The gateway fails closed at startup if this is unset and `CMCP_DEV_MODE` is not `1`. Format: `sha256:<hex>`. | none (startup policy integrity check) |
| `CMCP_CATALOG_HASH` | SHA-256 hash of the approved `catalog.json`. Required in non-dev mode. The gateway fails closed at startup if this is unset and `CMCP_DEV_MODE` is not `1`. Format: `sha256:<hex>`. | none (additional startup check) |
Expand Down
12 changes: 12 additions & 0 deletions src/cmcp_runtime/audit/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,18 @@ def append(
self._notify_sinks(entry)
return entry

def rotate_session_id(self, new_session_id: str) -> None:
"""Attribute subsequent entries to ``new_session_id``.

A credentialed reset closes one session and opens a successor on the same
hash-linked chain. Without this, every entry after a reset carries the
closed session's identifier and the successor's identifier appears
nowhere, so the record cannot say which session an entry belongs to.
Entries already appended are unchanged: they are hashed and remain
attributed to the session that produced them.
"""
self._session_id = new_session_id

def add_sink(self, sink: Callable[[AuditEntry], None]) -> None:
"""Register a read-only observer of appended entries. See __init__."""
self._sinks.append(sink)
Expand Down
1 change: 1 addition & 0 deletions src/cmcp_runtime/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def build_server(ctx: RuntimeContext) -> MCPServer:
audit_chain=audit_chain,
session=session,
bearer_token=ctx.config.bearer_token,
operator_token=ctx.config.operator_token,
)


Expand Down
26 changes: 26 additions & 0 deletions src/cmcp_runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,21 @@ class Config:
max_response_size_bytes: int = 2 * 1024 * 1024 # 2MB
policy_reload_interval_seconds: int = 0 # 0 = disabled (POLICY-001)
audit_db_path: str = "audit.db" # AUDIT-001: durable audit chain storage
#: Path to the shared session-state database. Unset keeps the accumulated
#: session-sensitivity value in the gateway process, which is correct for a
#: single instance and loses the value on restart. Set it to a path on a
#: volume every instance shares to make the ratchet hold per session across
#: instances and survive a restart. See ``session/store.py``.
session_state_path: str | None = None
dev_mode: bool = False
bearer_token: str | None = None
#: Credential for the operator interface (session reset, catalog exception).
#: Held separately from ``bearer_token`` so that the credential authorizing a
#: reset is not the credential an agent host already holds to invoke tools.
#: A reset lowers accumulated session sensitivity, so an agent able to
#: present its own tool-invocation token to the reset route could clear the
#: state that monotonicity exists to keep.
operator_token: str | None = None
#: AARM R6. A named conformance profile tightens defaults that stay
#: permissive for developers. None is the default, and nothing changes.
#: "aarm" requires an Agent Manifest binding, because R6 says every receipt
Expand Down Expand Up @@ -446,6 +459,14 @@ def load_config(path: str) -> Config:

dev_mode = DEV_MODE # TEE-002: use the frozen constant, never re-read from env
bearer_token = os.environ.get("CMCP_BEARER_TOKEN") or None
operator_token = os.environ.get("CMCP_OPERATOR_TOKEN") or None

if operator_token is not None and operator_token == bearer_token:
raise ConfigError(
"CMCP_OPERATOR_TOKEN must differ from CMCP_BEARER_TOKEN. The operator "
"credential authorizes a session-sensitivity reset and must not be "
"reachable by a holder of the tool-invocation credential."
)

default_listen_addr = (
"127.0.0.1:8443"
Expand All @@ -465,6 +486,9 @@ def load_config(path: str) -> Config:
policy_bundle_path = raw.get("policy_bundle_path", "policy/")
catalog_path = raw.get("catalog_path", "catalog.json")
audit_db_path = raw.get("audit_db_path", "audit.db")
session_state_path = raw.get("session_state_path") or None
if session_state_path is not None:
_check_no_traversal("session_state_path", session_state_path)
_check_no_traversal("policy_bundle_path", policy_bundle_path)
_check_no_traversal("catalog_path", catalog_path)
_check_no_traversal("audit_db_path", audit_db_path)
Expand Down Expand Up @@ -533,4 +557,6 @@ def load_config(path: str) -> Config:
conformance_profile=profile,
dev_mode=dev_mode,
bearer_token=bearer_token,
operator_token=operator_token,
session_state_path=session_state_path,
)
54 changes: 37 additions & 17 deletions src/cmcp_runtime/mcp/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ class _CallFinalizationState:
"""Per-invocation facts needed for honest terminal finalization."""

failure_stage: str = "call_entry"
# Session generation observed at call entry, so a response landing after an
# operator reset is not applied to the successor session.
reset_count: int | None = None
effect_boundary_state: _EffectBoundaryState = _EffectBoundaryState.PRE_TRANSPORT
request_payload_hash: str | None = None
response_payload_hash: str | None = None
Expand Down Expand Up @@ -926,6 +929,17 @@ async def call_tool(
) -> CallResult:
"""Run one call and guarantee one terminal on failure or cancellation."""
finalization = _CallFinalizationState()
# Adopt the session's shared value before anything evaluates this call.
# An instance joining a session another instance opened, or one that has
# restarted, would otherwise evaluate the first call against its own
# empty copy and permit what the session's accumulated value forbids.
# No-op when no shared store is configured.
await self._session.hydrate()
# The session generation this call was issued under, read after hydration
# so a reset performed on another instance is already visible. A reset
# arriving mid-call closes that session, and this response must not raise
# the successor.
finalization.reset_count = self._session.reset_count
try:
return await self._call_tool_impl(
call_id,
Expand Down Expand Up @@ -1361,17 +1375,17 @@ class above the tool's catalogued sensitivity_level. It can never lower
)
injection_detected = bool(scan.threats)
if not scan.allowed:
async with self._session.mutation_lock:
self._session.update_from_inspection(
call_id=call_id,
sensitivity_tags=(
[entry.sensitivity_level, declared_data_class]
if declared_data_class is not None
else [entry.sensitivity_level]
),
injection_detected=injection_detected,
response_allowed=False,
)
await self._session.apply_inspection(
call_id=call_id,
sensitivity_tags=(
[entry.sensitivity_level, declared_data_class]
if declared_data_class is not None
else [entry.sensitivity_level]
),
injection_detected=injection_detected,
for_reset_count=_finalization.reset_count,
response_allowed=False,
)
threat_categories = ",".join(
sorted({str(t.get("category", "unknown")) for t in scan.threats})
)
Expand Down Expand Up @@ -1441,12 +1455,18 @@ class above the tool's catalogued sensitivity_level. It can never lower
)
injection_threshold = None
_finalization.failure_stage = "session_update"
async with self._session.mutation_lock:
self._session.update_from_inspection(
call_id=call_id,
sensitivity_tags=response_sensitivity,
injection_detected=injection_detected,
response_allowed=True,
applied = await self._session.apply_inspection(
call_id=call_id,
sensitivity_tags=response_sensitivity,
injection_detected=injection_detected,
response_allowed=True,
for_reset_count=_finalization.reset_count,
)
if not applied:
logger.warning(
"SESSION_RESET_RACE: response for call_id=%s dropped from session "
"state; the session it was issued under was closed by a reset",
call_id,
)

# Step 5: egress Cedar policy check
Expand Down
Loading