Skip to content

fix(sessions): stop a session id being forked across two users - #906

Merged
philmerrell merged 1 commit into
developfrom
fix/session-id-cross-user-fork
Sep 1, 2026
Merged

fix(sessions): stop a session id being forked across two users#906
philmerrell merged 1 commit into
developfrom
fix/session-id-cross-user-fork

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Found while investigating prod session 5f34d2b0. Unrelated to the outage itself.

The bug

_get_session_by_gsi returns None both for "no such session" and for "exists, but owned by someone else". Callers can't tell those apart, so ensure_session_metadata_exists reads the second case as the first and creates a second metadata row on the same session id under the requester. Its attribute_not_exists(PK) guard can't catch this — the new row has a different PK (USER#{requester}), so the conditional put succeeds.

Session ids travel in shareable /s/{sessionId} URLs. Opening someone else's link 404s on the metadata read, but the SPA then treats the session as new and lets the user send. That send is what forked it.

What it is not

Not a confidentiality bug. Conversation content lives in AgentCore Memory keyed by actor id (get_messages_from_cloud passes actor_id=user_id), so the second user only ever saw an empty thread — never the owner's messages. Verified in the prod logs: Retrieved 0 memories from namespace: /strategies/.../actors/{second-user}/sessions/5f34d2b0....

What it actually broke

Beyond the duplicate row and the spend attached to it: both rows carry identical GSI_PK/GSI_SK, and DynamoDB returns them in an unspecified order. Every lookup read items[0], so after a fork the original owner's session could resolve to the other user's row, fail the ownership check, and look "not found" to every marker helper. That is why the prod logs show update_session_activity: session ... missing and could not be created against a session that plainly existed.

Changes

  • session_owned_by_other_user makes the distinction explicit. Fail-open — it guards a rare misuse and must never take the chat path down. Returns True only when rows exist and none belong to the caller, so a legitimate owner is never blocked even where a fork is already attached.
  • ensure_session_metadata_exists refuses to create over another owner (backstop for every pre-create path), and the invocations route rejects the turn with 404 — not 403, so the response says nothing about whether the session exists, matching what GET /sessions/{id}/metadata already returns.
  • Both GSI lookups now scan every returned row for the caller's own instead of reading items[0]. Forked rows already exist in prod, so the read path has to stay deterministic over them. There were two copies of this ownership check; they're both fixed.

Testing

7 new tests (moto). Note the fork can no longer be created through the write path at all, so the determinism tests write the second row raw — that's what's already sitting in prod.

Full backend suite: 6908 passed, 6 skipped.

Not included

No backfill for the rows already forked in prod. Worth a separate pass once this is deployed — happy to write it.

🤖 Generated with Claude Code

`_get_session_by_gsi` returned None both for "no such session" and for
"exists, but owned by someone else". Callers could not tell those apart,
so `ensure_session_metadata_exists` read the second case as the first and
created a SECOND metadata row on the same session id under the requester.
Its `attribute_not_exists(PK)` guard cannot catch this — the new row has a
different PK (`USER#{requester}`), so the conditional put succeeds.

Session ids travel in shareable `/s/{sessionId}` URLs. Opening someone
else's link 404s on the metadata read, but the SPA then treats the session
as new and lets the user send, which is what forked it. Observed in prod
on 2026-08-31.

NOT a confidentiality bug: conversation content lives in AgentCore Memory
keyed by actor id, so the second user only ever saw an empty thread. The
damage was the duplicate row, the spend attached to it, and — because both
rows share GSI_PK/GSI_SK and DynamoDB returns them in an unspecified order
— the original owner's session resolving non-deterministically afterwards,
which is why that turn logged "update_session_activity: session missing
and could not be created" against a session that plainly existed.

Three changes:
- `session_owned_by_other_user` makes the distinction explicit, fail-open.
- `ensure_session_metadata_exists` refuses to create over another owner,
  and the invocations route rejects the turn with 404 (not 403 — it says
  nothing about whether the session exists, matching the metadata GET).
- Both GSI lookups now scan every returned row for the caller's own,
  instead of reading items[0]. Forked rows already exist in prod, so the
  read path has to stay deterministic over them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 1ca4621 into develop Sep 1, 2026
4 checks passed
@philmerrell
philmerrell deleted the fix/session-id-cross-user-fork branch September 1, 2026 22:52
@philmerrell philmerrell mentioned this pull request Sep 2, 2026
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.

1 participant