Skip to content

fix(security): bind the production read-write role's jobs to a corpus-write environment - #2522

Merged
modelmirror merged 4 commits into
stagingfrom
fix/2511-corpus-write-environment
Oct 18, 2026
Merged

modelmirror merged 4 commits into
stagingfrom
fix/2511-corpus-write-environment

Conversation

@modelmirror-agent

Copy link
Copy Markdown
Contributor

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-predict predict, run-evaluate evaluate, run-backtest backtest) bind prod with id-token: write. The production read-write corpus role's OIDC trust names environment: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-write environment. The role's trust can then name that environment alone, and a prod token 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: prod to environment: corpus-write (9)

Each of these names vars.AWS_ROLE_TO_ASSUME, and no other job in the repository does:

Workflow Job
run-pull pull, live, enrich
run-seed seed, ocr-sweep
run-repair repair (the corpus passes), applications, decision-record, opinion-record

What stays on prod and why:

  • No role: run-pull chain-predict (dispatch App), run-seed order-votes and run-repair votes (vote writers holding only the data App token), and run-repair regrade (data App token).
  • Read-only role only: the projection jobs (run-seed order-votes-projection / ocr-sweep-projection, run-repair handoff-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-repair decision-record. Its dry run (and its pull, on an apply too) assumes the read-only role through corpus-readonly, in the same job. The minimal change keeps it as one job binding corpus-write in both modes. The read-only role's trust must therefore also name environment: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 under prod, 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:

  • Secrets:
    • the data App's private key (the pointer commit)
    • the CourtListener API token (run-pull)
  • Variables:
    • the production read-write role ARN
    • the production read-only role ARN (decision-record's pull)
    • the AWS region
    • the corpus estate's base URL
    • the data App's client id
    • run-pull's three optional CourtListener rate caps (they default if absent)

Each takes the value prod holds 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 make run-pull fall back to its default rates, so copy them too if prod tunes them.

The gate also refuses any name outside the list, and treats a listing whose total_count disagrees with its items as a truncated page, i.e. unread. The truncation rule applies to the corpus-check and website checks too.

Presence is required because prod holds 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 into corpus-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 prod and corpus-write. A rotation updates both.

Files

  • .github/workflows/run-pull.yml, run-seed.yml, run-repair.yml: the 9 environment: lines, plus the comments that named prod for 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 names prod, and the RW role trusts corpus-write alone.

  • .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:

    • (a) every job naming the RW role binds the literal corpus-write, including the indexed spelling and the role named in a workflow-level env;
    • (b) no other job binds it, an expression that could resolve to it included;
    • (c) no agent job binds it, using tests/test_workflow_token_holder.py's _runs_agent detector;
    • a pinned known-writer set, so the detector cannot go blind;
    • synthetic refusal cases;
    • the gate's admitted names equal exactly what the corpus-write jobs read.
  • tests/test_workflow_auth_gate.py: corpus-write added to the branch-policied environments.

  • tests/test_workflow_fork_pr_gate.py: corpus-write jobs are held unreachable on pull_request, as prod jobs are.

  • src/fedcourtsai/environment_checks.py, src/fedcourtsai/cli.py (assert-corpus-write-environment), scripts/promotion-gate.sh (contexts stage), tests/test_environment_checks.py: the admin-read check, run unconditionally like the corpus-check and website checks. It requires:

    • the environment exists;
    • its deployment branches are a custom policy of exactly main (a tag policy named main is refused);
    • no secret and no variable outside the writers' names;
    • every one of those names present, except the optional rate caps;
    • an unread listing fails, and so does a truncated one (a total_count / items mismatch).

    Required reviewers are not checked: the writers run on schedules, and a reviewer would hold every window. prod itself 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.py and the shared tests/agent_detector.py are maintainer-owned, beside test_workflow_auth_gate.py.

  • docs/security.md:

    • a new corpus-write holds the production read-write role section (configuration and provisioning order);
    • the role-trust paragraph rewritten: RW trusts corpus-write alone, and RO trusts prod, corpus-write, staging and corpus-check;
    • the 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).
    • The Claude GitHub App OIDC exchange is recorded as an accepted residual:
      • any job holding id-token: write can mint a token the exchange accepts, whatever its environment;
      • the App stays installed because it also serves Claude Code's own GitHub access;
      • no workflow uses the exchange, since every claude-code-action step passes github.token;
      • what bounds it: no ruleset bypass, the run gate, and no issue-triggered privileged workflow;
      • contents write could still publish a Release on an existing tag.
  • 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 role prod is trusted by" now reads as per-environment, and the corpus-write lock 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:

    • presence is required, and the missing names are reported;
    • a total_count / items mismatch reads as unread, for every environment the stage checks;
    • the tail in the gate test is bounded at the block's closing fi.
  • App residual: now names the full reach of a default installation token:

    • unreviewed merges into staging for PRs that touch no code-owned path, running staging-bound with the staging RW role and engine keys;
    • gate-read labels such as promote:skip-engine-smoke;
    • PR body edits;
    • pushes to unprotected branches (the workflows permission is presumed absent; verify);
    • public posts, a republication exposure for gated snapshot text;
    • Releases on an existing tag.

    It says the installation's grant is the maintainer's to confirm.

  • Parsers: corpus-write removes agents from the RW role's reach, not hostile parsers. The pull and seed windows' fetched dockets and repair's unsplit ocr-recovery / document-backfill passes 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:

    • environment expressions that fall through to the bare github.ref_name are allowed only on integration-test, run-analytics and summarize, a set the test pins, with the branch policy as the stated backstop;
    • local composites are followed;
    • the name-parity check reads indexed vars['X'] / secrets['X'] spellings too;
    • test_workflow_fork_pr_gate.py uses ec.CORPUS_WRITE_ENVIRONMENT.
  • Detector: moved into tests/agent_detector.py, shared by both invariants. It and tests/test_workflow_token_holder.py are added to CODEOWNERS.

The reviews ran as separate reviewer sessions on the first commit. A security-reviewer re-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.
  • The new invariant fails on origin/staging's workflows. With .github/workflows from origin/staging, test_only_the_writer_jobs_bind_corpus_write and the names test fail, reporting the 9 jobs as binding prod. With this branch's workflows, everything passes.
  • Full scripts/gate.sh test: 8243 passed, 2 failed, both environmental and untouched by this diff:
    • test_client.py::test_redirects_are_not_followed: proxy ProxyError in this container;
    • test_collect.py::test_union_refuses_an_unreadable_source_file_and_carries_on: runs as root, so chmod 000 does not block reads.
  • actionlint 1.7.12 with shellcheck (-e SC2016): clean. shellcheck on scripts/promotion-gate.sh: clean.
  • uvx zizmor@1.26.1 --offline --persona=regular .github/workflows .github/actions: no findings.

Maintainer steps, in order

  1. Read-only checks first.
    • 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:

      • that it lacks workflows;
      • which of contents, issues and pull-requests write it holds;
      • whether its OIDC exchange can be limited App-side.

      Record the answer against the accepted residual.

  2. Create and populate the corpus-write environment, and widen the trusts. (Settings and IAM; before merge.)
    • a. Create the environment corpus-write:
      • deployment branches: selected branches, one named custom branch policy, main (not "protected branches");
      • no required reviewers;
      • copy in, by role, every required secret and variable listed above, each with the value prod holds; copy the three CourtListener rate caps too if prod sets them: the gate does not require them, so leaving one out silently drops run-pull to its default rates.
    • b. Add repo:ModelMirrorAI/fedcourtsai:environment:corpus-write to the sub condition of:
      • the production read-write role's trust, keeping environment:prod for now;
      • the production read-only role's trust (for decision-record's pull).
    • c. From a checkout of this branch (main does not have the check yet), run scripts/promotion-gate.sh contexts with 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.
  3. Merge this PR to staging, then promote to main in the normal batch. The promotion gate's contexts stage now refuses a promotion while step 1a is missing or incomplete.
  4. Let one writer window succeed under corpus-write. A scheduled run-pull window is enough. A run-seed window and a run-repair dry run (e.g. decision-record) also cover the other two workflows and the read-only-under-corpus-write path.
  5. Narrow the RW trust: remove environment:prod from the production read-write role's trust. Then confirm that no write-capable role matches environment:prod, a repository-wide wildcard such as repo:ModelMirrorAI/fedcourtsai:*, or a ref-only or job_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, a prod-bound job (every agent cell) can mint AWS tokens only the read-only role accepts.
  6. Close security: an injected cell in a prod-bound job can mint an OIDC token the production read-write role and the Claude GitHub App accept #2511 by hand. The Claude App half is the documented accepted residual.

What breaks if the order is violated

  • Step 2 before step 1 (workflows bind corpus-write before it exists or before the trusts name it):

    • GitHub auto-creates corpus-write with no branch policy and no secrets/variables;
    • every writer job then resolves an empty role ARN (or one the trust refuses) and fails at its first credentialed step, so every pull, seed and repair window fails until fixed;
    • an auto-created, unpoliced corpus-write that 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 contexts gate refuses the promotion in this state, which is the backstop.

  • Step 1b before 1a (trust names corpus-write before the environment exists with its main-only policy): any write holder can push a branch whose workflow binds corpus-write and assumes the RW role.

  • Step 1b omitted for the read-only role: run-repair decision-record fails at its corpus-readonly pull, in both dry run and apply. Every other writer is unaffected.

  • Step 4 before step 2 (prod removed from the RW trust while the writers still bind prod): 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 a corpus-write deployment per writer job, and this lists fresh deployments dated after the promotion:

    gh api 'repos/ModelMirrorAI/fedcourtsai/deployments?environment=corpus-write&per_page=5' \
      --jq '.[] | [.created_at, .ref, .sha] | @tsv'
    

    The matching query for environment=prod no longer gains entries from run-pull pull/live windows.

  • The window succeeds: its pointer commit lands on main.

  • scripts/promotion-gate.sh contexts (maintainer's admin token, from main once 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 (sub names only environment:corpus-write).

Rollback

  • Before step 4: revert this PR (staging, then promote). The RW trust still names prod, so the writers work again under prod immediately. The extra corpus-write trust lines and the environment are inert, and can be removed at leisure.
  • After step 4: first re-add environment:prod to the RW role's trust, then revert the PR. Reverting first breaks every writer window until the trust is re-added.
  • The gate check reverts with the PR. If the environment is deleted while this PR is live, the gate refuses promotions and every writer fails, so delete it only after the revert is promoted.

Open questions

  • The data App key stays on prod too. prod jobs still use it: the vote writers, regrade and promotion-record. So this PR does not shrink what prod holds; 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.
  • No shadow-copy check like 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.
  • Required names assume environment-level values. If prod gets any required name from repository level today, the gate will name it as missing: copy it into corpus-write, or ask for it to be dropped from the required set.
  • Name overlap: the environment shares its name with the existing corpus-write concurrency lock, which the vote writer and regrade jobs take under prod. 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 is unchanged: the staging RW role still trusts staging, which the engine-smoke agent legs bind with id-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

…-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
@modelmirror
modelmirror marked this pull request as ready for review October 18, 2026 13:20
@modelmirror
modelmirror self-requested a review as a code owner October 18, 2026 13:20
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.

3 participants