Skip to content

fix(coord): scope /v1/coord/active to the caller's tenant (play03 M4 / D4) - #731

Merged
CueCrux-Myles merged 1 commit into
mainfrom
fix/coord-active-tenant-isolation
Aug 21, 2026
Merged

CueCrux-Myles merged 1 commit into
mainfrom
fix/coord-active-tenant-isolation

Conversation

@CueCrux-Myles

Copy link
Copy Markdown
Contributor

What landed

GET /v1/coord/active no longer returns other tenants' live sessions.

get_coord_active has resolved the caller's authorization tenant since a413ce6 (2026-08-10, refs #630) and uses it to scope work_in_flight, but crate::coord::assemble_active took no tenant argument at all — it walked every SessionBinding in the fact store. A tenant-A caller holding admin:read therefore received every tenant's live sessions, their passports, their declared ExecPlan focus, and the punchcard leases joined off them. This is the still-open half of play03 defect D4.

The change is a parameter and a predicate:

  • crates/corecruxd/src/coord.rs — assemble_active takes tenant_id: &str and skips any binding whose tenant_id differs.
  • crates/corecruxd/src/http/coord.rs — the one caller passes the tenant it had already resolved.

SessionBinding.tenant_id already existed and was already copied into CoordSessionView.tenant_id, so no data-model change was needed. Filtering bindings is sufficient because intents join on session_id_hex and leases join on passport_id, both taken from a surviving binding: a dropped binding takes its intent and its leases with it.

Shape choices

  • &str, not Option<&str>. An optional tenant is exactly how this class of leak survives a refactor. Making it mandatory means a future caller cannot forget to scope and silently reopen it.
  • Strict equality, fails closed. session_bindings::resolve stamps unspecified bindings with the personal placeholder, while a token carrying no tenant claim resolves to default. An operator whose sessions were minted without a tenant will see an empty board until the two labels agree (pass tenant_id= on the read, or map the agent's tenant). That is deliberate: an empty board is visible and config-fixable, a cross-tenant read is silent. The crux-mcp path is unaffected in shape — handle_coord_status already sends ctx.scope_tenant() explicitly.

How it was verified

Two new tests, both of which fail on the unpatched predicate (verified by temporarily disabling only the if b.tenant_id != tenant_id check and re-running, keeping the signature so the failure isolates the leak):

Test Asserts
coord::tests::assemble_active_scopes_sessions_to_the_callers_tenant Tenant-A reader gets only A's session and only A's intent slug; tenant-B reader gets only B's; an unbound tenant-C gets an empty board rather than everyone's.
http::coord::tests::coord_active_never_returns_another_tenants_sessions End-to-end through get_coord_active: two seeded sessions in tenant-a / tenant-b announce focus; the tenant-A response body contains aaaa only and does not contain the string plan-b-secret anywhere.

Both tenants in each test bind to the same passport on purpose, so a passport-level filter would still leak — only the binding's tenant separates them.

Red-run evidence (predicate disabled):

assertion `left == right` failed: tenant-b session must not surface: ["aaaa", "bbbb"]
assertion `left == right` failed: tenant-a must see exactly its own session:
  {... "active_sessions":[{"session_id_hex":"aaaa","tenant_id":"tenant-a",...},
                          {"session_id_hex":"bbbb","tenant_id":"tenant-b",
                           "intent":{"execplan_slug":"plan-b-secret",...}}]}

Gates, all run locally in the worktree (exit codes captured directly, not through a pipeline):

cargo fmt --all --check                     EXIT=0
typos                                       EXIT=0
bash scripts/check-licence-headers.sh       EXIT=0  (601 crate .rs files)
bash scripts/unwrap-ratchet.sh              EXIT=0  (baseline_total=389 current_total=389)
bash scripts/assert-daemon-release-boundary.sh EXIT=0
cargo clippy --workspace -- -D warnings     EXIT=0
cargo test -p corecruxd --bin corecruxd -- coord  EXIT=0  (32 passed, 0 failed)
cargo test -p corecruxd --bin corecruxd           EXIT=0  (3125 passed, 0 failed, 2 ignored)

Five existing http::coord tests now pass tenant_id: Some("personal") on their ActiveQuery — that is the tenant their seeded bindings actually carry, so the assertions are unchanged in substance and now state their scope explicitly instead of relying on an unscoped read.

Left open

  • Leases are still read tenant-unfiltered from the entity store and scoped only by the holding passport, so a passport bound in two tenants surfaces the same lease on both boards. Closing that requires LeaseSummary to carry a tenant — a data-model change, out of this milestone.
  • Write/read tenant labels do not agree by default. cuecrux_session sends no tenant_id to /v1/session, so MCP-minted bindings land in personal while scope_tenant() typically resolves default. After this change those boards read empty rather than leaky. Aligning them means either stamping the binding from the authenticated tenant or having cuecrux_session pass one — both change which default passport binds (category personal vs work, and with it agent_work_gate), so it is a deliberate decision rather than a drive-by fix. Flagging for a human before the M4 deploy gate.
  • The ExecPlan's HIGH gate on M4 is a deploy gate (Rollout/rollback section), not a start gate; this PR lands code only.

ExecPlan: play03-custody-coord-remediation-2026-07, milestone M4 (defect D4). The plan file is reconciled centrally and is not touched here.

🤖 Generated with Claude Code

`get_coord_active` has resolved the caller's authorization tenant since
a413ce6 and used it to scope `work_in_flight`, but `assemble_active`
took no tenant at all — it walked every session binding in the store. A
tenant-A caller holding `admin:read` therefore received every tenant's
live sessions, their passports, their declared ExecPlan focus, and the
punchcard leases joined off them. That is the still-open half of play03
defect D4: the plan's M4 gate ("a token in tenant A calling coord_status
sees zero tenant-B rows") failed on origin/main.

The fix is a parameter and a predicate, not a data-model change:
`SessionBinding.tenant_id` already exists and is already copied into
`CoordSessionView.tenant_id`, so `assemble_active` now takes the resolved
tenant and drops any binding that does not match it. Filtering bindings
is sufficient because intents join on `session_id_hex` and leases join on
`passport_id`, both taken from a surviving binding — a dropped binding
takes its intent and leases with it. The test proves that specifically by
binding both tenants to the *same* passport, so a passport-level filter
would still leak and only the tenant predicate separates them.

Two shape choices worth stating:

- `tenant_id` is `&str`, not `Option<&str>`. An optional tenant is how
  this leak survives a refactor; making it mandatory means a new caller
  cannot forget to scope and silently reopen it.
- The predicate is strict equality, so it fails closed. `resolve` stamps
  unspecified bindings with the `personal` placeholder while a token with
  no tenant claim resolves to `default`, so an operator whose sessions
  were minted without a tenant will see an empty board until the two
  labels agree (pass `tenant_id=`, or map the agent's tenant). That is a
  deliberate trade: an empty board is visible and config-fixable, a
  cross-tenant read is silent. The `crux-mcp` path already sends its own
  tenant (`handle_coord_status` -> `ctx.scope_tenant()`), so it is
  unaffected in shape, only in scope.

Not addressed here: leases are still read tenant-unfiltered from the
entity store and scoped only by the holding passport, so a passport bound
in two tenants surfaces the same lease on both boards. Closing that needs
`LeaseSummary` to carry a tenant, which is a data-model change and out of
this milestone.

ExecPlan: play03-custody-coord-remediation-2026-07, milestone M4 (D4).
The plan's HIGH gate on M4 is a deploy gate (Rollout section), not a
start gate; this lands the code only.

Verified: the two new tests fail on the unpatched predicate —
`assemble_active_scopes_sessions_to_the_callers_tenant` reports
`["aaaa", "bbbb"]` and `coord_active_never_returns_another_tenants_sessions`
shows tenant-B's `plan-b-secret` intent in tenant-A's response body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@CueCrux-Myles
CueCrux-Myles force-pushed the fix/coord-active-tenant-isolation branch from 13a0e5e to cdc12dc Compare August 20, 2026 20:18
@CueCrux-Myles
CueCrux-Myles added this pull request to the merge queue Aug 20, 2026
Merged via the queue into main with commit 48645cb Aug 21, 2026
24 checks passed
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