Repository navigation
fix(security): bind the production read-write role's jobs to a corpus-write environment - #2522
Merged
Merged
Conversation
…-write environment The agent cell jobs bind `prod` with `id-token: write`, and the production read-write corpus role's OIDC trust names `environment:prod`, so a prompt-injected cell could mint a token that role accepts. Move exactly the jobs that assume the read-write role (run-pull pull/live/enrich, run-seed seed/ocr-sweep, run-repair repair/applications/decision-record/opinion-record) to a dedicated `corpus-write` environment, so the role's trust can name that environment alone. - tests/test_workflow_corpus_write.py: every job naming the read-write role binds the literal `corpus-write`, no other job binds it, no agent job binds it, and the gate's admitted names equal what those jobs read. - environment_checks + assert-corpus-write-environment + promotion-gate.sh contexts stage: the environment exists, admits `main` alone, and holds nothing outside the writers' names. - docs/security.md, SECURITY.md, docs/data-pipeline.md, docs/pipeline.md, docs/cli.md and the workflow comments: the new trust design, and the Claude GitHub App's OIDC exchange recorded as an accepted residual. Part of #2511: the repo half. The environment and IAM steps are the maintainer's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126DwPpqinYddgnRmh7Ljfn
Review round on the corpus-write environment change. - Docs and comments that stated the read-write role's narrowed trust as fact now tie it to the maintainer's IAM step the gate cannot read (SECURITY.md, docs/security.md, docs/data-pipeline.md, run-predict, run-evaluate, run-pull, promotion-gate.sh, environment_checks). - The gate requires the writers' names to be present, not only nothing extra (every secret and variable but the optional CourtListener rate caps), and a listing whose total_count disagrees with its items is a truncated page and reads as unread, for every environment the stage checks. - docs/security.md: the Claude App residual names the full reach of a default installation token (unreviewed staging merges, gate-read labels, PR bodies, unprotected branches, public posts, Releases) and leaves its actual grant to the maintainer; corpus-write removes agents, not hostile parsers, from the write role's reach; the duplicated names rotate in both environments. - tests: the agent detector moves to tests/agent_detector.py, code-owned with test_workflow_token_holder.py; the corpus-write invariant follows local composites, reads indexed secret/variable spellings, and pins the ref-name fall-through to the three branch-resolving workflows. Part of #2511. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126DwPpqinYddgnRmh7Ljfn
…step everywhere; make copying the rate caps explicit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126DwPpqinYddgnRmh7Ljfn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126DwPpqinYddgnRmh7Ljfn
modelmirror
marked this pull request as ready for review
October 18, 2026 13:20
modelmirror
approved these changes
Oct 18, 2026
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.
Part of #2511. This is the repo half. The environment and IAM steps below are the maintainer's, and the docs this PR changes describe the design. They are true only once the maintainer has done steps 1 and 4 below.
What changed and why
The agent cell jobs (
run-predictpredict,run-evaluateevaluate,run-backtestbacktest) bindprodwithid-token: write. The production read-write corpus role's OIDC trust namesenvironment:prod, so a prompt-injected cell could mint a token that role accepts.This PR moves exactly the jobs that assume the read-write role to a dedicated
corpus-writeenvironment. The role's trust can then name that environment alone, and aprodtoken reaches only the read-only role.Artifact served: the production corpus (index blob and content store). After the IAM narrowing, only the writer jobs can write it.
Jobs moved from
environment: prodtoenvironment: corpus-write(9)Each of these names
vars.AWS_ROLE_TO_ASSUME, and no other job in the repository does:run-pullpull,live,enrichrun-seedseed,ocr-sweeprun-repairrepair(the corpus passes),applications,decision-record,opinion-recordWhat stays on
prodand why:run-pullchain-predict(dispatch App),run-seedorder-votesandrun-repairvotes(vote writers holding only the data App token), andrun-repairregrade(data App token).run-seedorder-votes-projection/ocr-sweep-projection,run-repairhandoff-projection).Nothing else changed: job set, steps, permissions, concurrency groups and conditions are all identical. Only the
environment:line and comments differ.One job does more than assume the RW role:
run-repairdecision-record. Its dry run (and its pull, on an apply too) assumes the read-only role throughcorpus-readonly, in the same job. The minimal change keeps it as one job bindingcorpus-writein both modes. The read-only role's trust must therefore also nameenvironment:corpus-write(step 1b), or that job's pull fails. The alternative was to split the job or make its environment conditional on the mode. Either changes the job's shape, so I did not do it. The dry run was already able to mint a RW token underprod, so this is no wider.What the environment must hold, by role (no values here)
These are exactly what the 9 jobs read, as a test asserts:
run-pull)decision-record's pull)run-pull's three optional CourtListener rate caps (they default if absent)Each takes the value
prodholds today. The gate now enforces presence. Every secret and variable above must be held by the environment itself, except the three rate caps, which are optional. Unset caps makerun-pullfall back to its default rates, so copy them too ifprodtunes them.The gate also refuses any name outside the list, and treats a listing whose
total_countdisagrees with its items as a truncated page, i.e. unread. The truncation rule applies to thecorpus-checkandwebsitechecks too.Presence is required because
prodholds each of these as an environment value today; the docs say so, and the base URL is one per environment. If any of them is in fact served at repository level, the gate names it as missing. Either copy it intocorpus-write, or tell me and I will drop it from the required set.After this change the data App key and client id, the CourtListener token and the corpus base URL live on both
prodandcorpus-write. A rotation updates both.Files
.github/workflows/run-pull.yml,run-seed.yml,run-repair.yml: the 9environment:lines, plus the comments that namedprodfor these jobs..github/workflows/run-predict.yml,run-evaluate.yml: the cell jobs' sidecar comment now states the post-change truth. The read-only role is the one corpus role whose trust namesprod, and the RW role trustscorpus-writealone..github/workflows/staging-corpus-refresh.yml,scripts/promotion-gate.sh(staging comment): dropped the "in prod too, the write role trusts the environment every reader binds" symmetry claim.tests/test_workflow_corpus_write.py(new): the invariant:corpus-write, including the indexed spelling and the role named in a workflow-level env;tests/test_workflow_token_holder.py's_runs_agentdetector;corpus-writejobs read.tests/test_workflow_auth_gate.py:corpus-writeadded to the branch-policied environments.tests/test_workflow_fork_pr_gate.py:corpus-writejobs are held unreachable onpull_request, asprodjobs are.src/fedcourtsai/environment_checks.py,src/fedcourtsai/cli.py(assert-corpus-write-environment),scripts/promotion-gate.sh(contextsstage),tests/test_environment_checks.py: the admin-read check, run unconditionally like thecorpus-checkandwebsitechecks. It requires:main(a tag policy namedmainis refused);total_count/ items mismatch).Required reviewers are not checked: the writers run on schedules, and a reviewer would hold every window.
proditself has no admin-read check in the gate today, so there was no prod protection check to mirror beyond the branch policy..github/CODEOWNERS: the new invariant test,tests/test_workflow_token_holder.pyand the sharedtests/agent_detector.pyare maintainer-owned, besidetest_workflow_auth_gate.py.docs/security.md:corpus-writeholds the production read-write role section (configuration and provisioning order);corpus-writealone, and RO trustsprod,corpus-write,stagingandcorpus-check;prod-environment inventory, the deployment-branch list, the run-repair App-table row, the projection/writers passage, the staging-role paragraphs and runbook step 2 (the old "in prod too" symmetry).id-token: writecan mint a token the exchange accepts, whatever its environment;claude-code-actionstep passesgithub.token;SECURITY.md: a minimal factual update of the same. Role scoping, the cell-residual bullet, the trigger-surface environment list, and the environment inventory.docs/data-pipeline.md: "either roleprodis trusted by" now reads as per-environment, and thecorpus-writelock is disambiguated from the environment.docs/pipeline.md: the trust-boundary environment list.docs/cli.md: a row for the new command.Review round (second commit)
Workflow and code reviews had no blockers; the security review had one. All of it is resolved in the second commit:
Blocker: every sentence that stated the narrowed RW trust as present fact is now tied to the maintainer's IAM step, which the gate cannot read, and points at the provisioning-order paragraph. That covers SECURITY.md (cell residual, role scoping), docs/security.md (role-trust paragraph, staging-radius paragraph, staging runbook, run-repair table row, projection passage), docs/data-pipeline.md, and the comments in run-predict, run-evaluate, run-pull, promotion-gate.sh and environment_checks. The gate is described as checking only the environment.
Gate:
total_count/ items mismatch reads as unread, for every environment the stage checks;tailin the gate test is bounded at the block's closingfi.App residual: now names the full reach of a default installation token:
stagingfor PRs that touch no code-owned path, running staging-bound with the staging RW role and engine keys;promote:skip-engine-smoke;workflowspermission is presumed absent; verify);It says the installation's grant is the maintainer's to confirm.
Parsers:
corpus-writeremoves agents from the RW role's reach, not hostile parsers. The pull and seed windows' fetched dockets andrepair's unsplitocr-recovery/document-backfillpasses still parse under the RW role. Stated in docs/security.md and SECURITY.md.Rotation: the duplicated names need updating in both environments; stated in docs/security.md.
Invariant test:
github.ref_nameare allowed only onintegration-test,run-analyticsandsummarize, a set the test pins, with the branch policy as the stated backstop;vars['X']/secrets['X']spellings too;test_workflow_fork_pr_gate.pyusesec.CORPUS_WRITE_ENVIRONMENT.Detector: moved into
tests/agent_detector.py, shared by both invariants. It andtests/test_workflow_token_holder.pyare added to CODEOWNERS.The reviews ran as separate reviewer sessions on the first commit. A
security-reviewerre-review of the second commit found no blockers. It confirmed every round-1 item resolved and that the workflows still move exactly the nine jobs. Its remaining wording items (four sentences that still read the narrowed trust as present, and making the rate-cap copy an explicit provisioning step) are fixed in the third commit. Every touched file is maintainer-owned, so this PR is left for the maintainer and not self-merged into staging.Checks run (at the second commit)
uv sync;scripts/gate.sh lint types docs-tests: green.pytest tests/test_workflow_*.py tests/test_environment_checks.py tests/test_ruleset_checks.py tests/test_ci_lane.py tests/test_required_checks.py: 691 passed..github/workflowsfromorigin/staging,test_only_the_writer_jobs_bind_corpus_writeand the names test fail, reporting the 9 jobs as bindingprod. With this branch's workflows, everything passes.scripts/gate.sh test: 8243 passed, 2 failed, both environmental and untouched by this diff:test_client.py::test_redirects_are_not_followed: proxyProxyErrorin this container;test_collect.py::test_union_refuses_an_unreadable_source_file_and_carries_on: runs as root, sochmod 000does not block reads.-e SC2016): clean. shellcheck onscripts/promotion-gate.sh: clean.uvx zizmor@1.26.1 --offline --persona=regular .github/workflows .github/actions: no findings.Maintainer steps, in order
Confirm the repository's OIDC subject template is the default
repo:<owner>/<repo>:environment:<name>(gh api repos/ModelMirrorAI/fedcourtsai/actions/oidc/customization/sub) before widening any trust on that shape.Check the Claude GitHub App installation's permissions:
workflows;Record the answer against the accepted residual.
corpus-writeenvironment, and widen the trusts. (Settings and IAM; before merge.)corpus-write:main(not "protected branches");prodholds; copy the three CourtListener rate caps too ifprodsets them: the gate does not require them, so leaving one out silently dropsrun-pullto its default rates.repo:ModelMirrorAI/fedcourtsai:environment:corpus-writeto thesubcondition of:environment:prodfor now;decision-record's pull).maindoes not have the check yet), runscripts/promotion-gate.sh contextswith your admin token. It should print "the corpus-write environment admits main alone and holds only the writers' names". Any missing or extra name is named in the error.contextsstage now refuses a promotion while step 1a is missing or incomplete.corpus-write. A scheduledrun-pullwindow is enough. Arun-seedwindow and arun-repairdry run (e.g.decision-record) also cover the other two workflows and the read-only-under-corpus-writepath.environment:prodfrom the production read-write role's trust. Then confirm that no write-capable role matchesenvironment:prod, a repository-wide wildcard such asrepo:ModelMirrorAI/fedcourtsai:*, or a ref-only orjob_workflow_ref-only subject. That covers the production RW role, the staging RW role, and any other role in any account or cloud. From here, aprod-bound job (every agent cell) can mint AWS tokens only the read-only role accepts.What breaks if the order is violated
Step 2 before step 1 (workflows bind
corpus-writebefore it exists or before the trusts name it):corpus-writewith no branch policy and no secrets/variables;corpus-writethat the RW trust already named would hand the RW role to any write holder's branch workflow, which is why step 1a must precede 1b.The
contextsgate refuses the promotion in this state, which is the backstop.Step 1b before 1a (trust names
corpus-writebefore the environment exists with itsmain-only policy): any write holder can push a branch whose workflow bindscorpus-writeand assumes the RW role.Step 1b omitted for the read-only role:
run-repairdecision-recordfails at itscorpus-readonlypull, in both dry run and apply. Every other writer is unaffected.Step 4 before step 2 (
prodremoved from the RW trust while the writers still bindprod): every writer window fails AssumeRole.Effect check (handover for the promotion body)
Once promoted, and with step 1 done:
A writer run's jobs show environment
corpus-write. The run page shows acorpus-writedeployment per writer job, and this lists fresh deployments dated after the promotion:The matching query for
environment=prodno longer gains entries fromrun-pullpull/livewindows.The window succeeds: its pointer commit lands on
main.scripts/promotion-gate.sh contexts(maintainer's admin token, frommainonce promoted) passes, including the new "the corpus-write environment admits main alone and holds only the writers' names" line.After step 4, an executed check: a
prod-bound job that tries the RW role is refused. Nothing in the repo can show this, so it is the maintainer's IAM reading of the RW trust (subnames onlyenvironment:corpus-write).Rollback
prod, so the writers work again underprodimmediately. The extracorpus-writetrust lines and the environment are inert, and can be removed at leisure.environment:prodto the RW role's trust, then revert the PR. Reverting first breaks every writer window until the trust is re-added.Open questions
prodtoo.prodjobs still use it: the vote writers,regradeandpromotion-record. So this PR does not shrink whatprodholds; it only narrows who can mint a RW AWS token. No agent job names it (the token-holder test), so it is not delivered to cells.website's for the data App key at repository level. Adding one might fail the gate on today's layout, which I could not read.prodgets any required name from repository level today, the gate will name it as missing: copy it intocorpus-write, or ask for it to be dropped from the required set.corpus-writeconcurrency lock, which the vote writer andregradejobs take underprod. docs/security.md says so explicitly. Rename the environment (e.g.corpus-writer) if the overlap bothers you; it is a one-constant change plus docs.staging, which the engine-smoke agent legs bind withid-token: write. That was already documented as provenance-only with the env scrub load-bearing, and it now reads as a deliberate asymmetry.Generated by Claude Code