security: isolate JWT fact-backed tenants (red-steel S3c) - #745
Merged
Merged
Conversation
Default wired HTTP fact-backed surfaces to verified JWT tenant stamping while retaining explicit off and shadow migration modes. Freeze the posture at startup, fail closed on ambiguous authority, and tenant-bind paired reads, caches, receipts, overlays, candidates, replay, and result-envelope flows. Co-Authored-By: OpenAI Codex <codex@openai.com>
The replay of 426ea8a applies to current main with only two conflicts, both in generated files, but it does not compile: main has grown call sites and tests against the weaker API the commit replaces. Migrated deliberately rather than reverted, one note per site. Production: - escrow.rs get_wrapped_dek — tenant_hash_for_read_context is now fail-closed and returns Result. This route landed after the branch was written, so it was never updated; it now propagates the problem response like every other fact-backed route. - replay.rs store_answer_capsule — takes an explicit tenant_hash instead of hardcoding "default". Five test call sites pass "default", which is exactly the value the function used to bake in, so their behaviour is unchanged. Tests that asserted the old contract, each migrated to assert the new one: - tenant_stamp_mode_from_env_reads_the_real_env -> ..._from_env_for_auth_defaults_on_for_jwt_and_rejects_junk. Two changes are the point of the commit and are now pinned: under JWT an unset variable resolves to On rather than Off, and an unrecognised value is an error rather than a silent fall to Off. A typo in a deployment used to disable tenant isolation and say nothing. - scope_context_write_and_read_tenant_follow_the_env_posture -> ..._tenant_resolution_uses_the_frozen_posture_not_the_env. The posture is now frozen into Authz at startup, so the old name described the behaviour this commit removes. The test now pins that a late set_var cannot move an already authenticated caller between tenants, and keeps the assertions that still carry weight (On resolves the token's tenant, an unowned selector is 403, Shadow neither stamps nor rejects — built directly, since the posture no longer comes from the environment). - multi_tenant_token_needs_a_selector_when_stamping_is_on — the read side used to fall back to reading `default` when a multi-tenant token named no tenant. That ambiguity is what the commit closes, so the read is now refused in lockstep with the write. CHANGELOG resolution kept only 426ea8a's own entry; the merge pulled in adjacent text belonging to c093c00 (S5, not landed), which would have claimed coordination and punchcard work had shipped. llms-full.txt regenerated with scripts/build-llms-full.sh rather than hand-merged. Status: cargo check --workspace --all-targets --locked clean; 3167 of 3168 corecruxd tests pass. ONE test fails and is NOT addressed here because it is a policy question, not a merge error — see the next commit message / plan note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s not Resolves the one disagreement between this slice and main. 426ea8a routes fact-backed reads through fact_visible_to_agent, which hides born-private reserved namespaces from a caller with no passport. Main's b36a72b guarantees the opposite for the addressed path: internal namespaces are excluded from UNDIRECTED recall but stay reachable when named, so nothing becomes unreachable. Both rules are right; they met on one code path. On an auth-off daemon passport_id is None and the operator is nonetheless the owner, so applying the first rule everywhere made the daemon's own seeded manual unreadable through /v1/context even by name — precisely the outcome b36a72b exists to prevent. Operator decision, taken 2026-08-22: exempt seeded documentation only. The distinction is content, not prefix shape. __bootstrap__:: is documentation the daemon seeded about itself and is safe to hand to anyone who can already reach the port; __agent:: and __ops:: are private state whose disclosure would be a real leak — wider than the problem being solved. The exemption is therefore one namespace wide and addressed-only; undirected recall still excludes every internal namespace. Adds fact_privacy::is_addressable_without_agent_identity so the rule has one home and a name, rather than a prefix literal at the filter. Both directions are tested, because only testing the permissive one would let the exemption widen silently: - addressing_an_internal_entity_still_returns_it (main's, now passing again) - addressing_private_daemon_state_without_an_agent_returns_nothing (new). Positive-controlled: widening the helper to is_internal_namespace fails it with __agent::alice::secret in the response. Gates: cargo test --workspace --no-fail-fast --locked 8339 passed / 0 failed; clippy --workspace -D warnings clean; fmt clean; unwrap-ratchet OK (389/389). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CueCrux-Myles
enabled auto-merge
August 22, 2026 11:49
The rustdoc gate caught a real interaction between this replay and newer main code. `426ea8a0` makes `promote_for_tenant` / `reject_for_tenant` the real API and demotes `promote` / `reject` to `#[cfg(test)]` wrappers, but the module doc — added on main by `bcd38b00`, after this branch was written — links to the bare names. Outside a test build those items no longer exist, so `cargo doc -D warnings` fails with 'unresolved link to promote'. Repoint the links at the functions that actually exist in a non-test build. Verified with the gate CI runs: RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --no-deps, exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Continues the red-steel replay (#630) per
redsteel-remediation-replay-2026-08-07. Replays426ea8a0onto currentmain.The measurement matters more than the diff
The plan estimated 9 conflicts for this slice. Replayed in branch order after #686 landed, the actual count is 2 — both in generated files (
CHANGELOG.md,llms-full.txt), zero code conflicts. The remaining slices should be re-measured before scheduling rather than costed off the 2026-08-07 figures.Three commits, deliberately separate
1.
426ea8a0cherry-picked. JWT HTTP fact surfaces default to verified tenant stamping; the posture is frozen at startup, ambiguous authority fails closed, and paired reads/caches/receipts/overlays/candidates/replay/result-envelopes stay on one authorized tenant.The CHANGELOG resolution kept only
426ea8a0's own entry. The three-way merge pulled in adjacent text belonging toc093c006(S5, not landed), which would have published a claim that coordination and punchcard binding had shipped.llms-full.txtwas regenerated withscripts/build-llms-full.sh, never hand-merged.2. Migrating main's call sites. It applies cleanly but does not compile:
mainhas grown call sites and tests against the weaker API this commit replaces. Each migration is noted at the site.escrow.rs— this route landed after the branch was written, so it never learned thattenant_hash_for_read_contextis fail-closed; it now propagates the problem response like every other fact-backed route.replay.rs—store_answer_capsuletakes an explicit tenant instead of hardcoding"default"; the five test call sites pass"default", the value it used to bake in, so their behaviour is unchanged.Tests that asserted the old contract, migrated rather than deleted:
tenant_stamp_mode_from_env_reads_the_real_envOn, notOff, and an unrecognised value is an error rather than a silent fall toOff. A typo in a deployment used to disable tenant isolation and say nothing.scope_context_write_and_read_tenant_follow_the_env_postureAuthzat startup — the old name described the behaviour this commit removes. A lateset_varcan no longer move an authenticated caller between tenants.multi_tenant_token_needs_a_selector_when_stamping_is_ondefault.3. Resolving one genuine disagreement.
426ea8a0routes fact-backed reads throughfact_visible_to_agent, which hides born-private reserved namespaces from a caller with no passport. Main'sb36a72b1guarantees the opposite for the addressed path: internal namespaces are out of undirected recall but stay reachable when named. Both are right; they met on one code path. On an auth-off daemonpassport_idisNoneand the operator is still the owner, so applying the first rule everywhere made the daemon's own seeded manual unreadable through/v1/contexteven by name.Isolated by instrumentation, not inference — the fact is present in the tenant and
entity_matches_for_agent(…, None)istrue, whilefact_visible_to_agent(fact, None)isfalse.Operator decision, 2026-08-22: exempt seeded documentation only. The distinction is content, not prefix shape.
__bootstrap__::is documentation the daemon seeded about itself, safe for anyone who can already reach the port;__agent::and__ops::are private state whose disclosure would be wider than the problem being solved. The exemption is one namespace wide and addressed-only; undirected recall still excludes every internal namespace.Both directions are tested, because testing only the permissive one would let the exemption widen silently. The boundary test is positive-controlled: widening the helper to
is_internal_namespacereturns__agent::alice::secretand fails it.Behaviour changes reviewers should expect
CORECRUXD_TENANT_WRITE_STAMPunset now isolate by tenant (previously shareddefault).Off.Gates:
cargo test --workspace --no-fail-fast --locked8339 passed / 0 failed ·clippy --workspace -D warningsclean · fmt clean · unwrap-ratchet 389/389.🤖 Generated with Claude Code