Repository navigation
fix(coord): scope /v1/coord/active to the caller's tenant (play03 M4 / D4) - #731
Merged
Merged
Conversation
`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
force-pushed
the
fix/coord-active-tenant-isolation
branch
from
August 20, 2026 20:18
13a0e5e to
cdc12dc
Compare
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What landed
GET /v1/coord/activeno longer returns other tenants' live sessions.get_coord_activehas resolved the caller's authorization tenant since a413ce6 (2026-08-10, refs #630) and uses it to scopework_in_flight, butcrate::coord::assemble_activetook no tenant argument at all — it walked everySessionBindingin the fact store. A tenant-A caller holdingadmin:readtherefore 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_activetakestenant_id: &strand skips any binding whosetenant_iddiffers.crates/corecruxd/src/http/coord.rs— the one caller passes the tenant it had already resolved.SessionBinding.tenant_idalready existed and was already copied intoCoordSessionView.tenant_id, so no data-model change was needed. Filtering bindings is sufficient because intents join onsession_id_hexand leases join onpassport_id, both taken from a surviving binding: a dropped binding takes its intent and its leases with it.Shape choices
&str, notOption<&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.session_bindings::resolvestamps unspecified bindings with thepersonalplaceholder, while a token carrying no tenant claim resolves todefault. An operator whose sessions were minted without a tenant will see an empty board until the two labels agree (passtenant_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. Thecrux-mcppath is unaffected in shape —handle_coord_statusalready sendsctx.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_idcheck and re-running, keeping the signature so the failure isolates the leak):coord::tests::assemble_active_scopes_sessions_to_the_callers_tenanthttp::coord::tests::coord_active_never_returns_another_tenants_sessionsget_coord_active: two seeded sessions intenant-a/tenant-bannounce focus; the tenant-A response body containsaaaaonly and does not contain the stringplan-b-secretanywhere.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):
Gates, all run locally in the worktree (exit codes captured directly, not through a pipeline):
Five existing
http::coordtests now passtenant_id: Some("personal")on theirActiveQuery— 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
LeaseSummaryto carry a tenant — a data-model change, out of this milestone.cuecrux_sessionsends notenant_idto/v1/session, so MCP-minted bindings land inpersonalwhilescope_tenant()typically resolvesdefault. After this change those boards read empty rather than leaky. Aligning them means either stamping the binding from the authenticated tenant or havingcuecrux_sessionpass one — both change which default passport binds (categorypersonalvswork, and with itagent_work_gate), so it is a deliberate decision rather than a drive-by fix. Flagging for a human before the M4 deploy gate.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