The concrete wiring behind each invariant — the GitHub Apps, rulesets, the
prod environment, and the IAM roles/policies — lives in the operational
runbook, docs/security.md.
- Pin actions to a full commit SHA, with the version in a trailing comment.
zizmor(inlint-actions.yml) fails the build on unpinned actions; Dependabot bumps the pins. - Pin Python dependencies to
uv.lock, and install with--locked. The lockfile carries a hash per artifact, so it is the supply-chain control for the project's own dependencies — the counterpart of the action SHAs above. Every job that installs them goes through.github/actions/setup-python-env, whoseuv sync --lockedrefuses a lock that has drifted frompyproject.tomlrather than re-resolving. Dependabot bumps it. The packagesuvxresolves at run time are not in that lock and are pinned by version alone: the CourtListener MCP server named inconfig/predictors.yaml/config/evaluators.yaml, and the workflow linters. A third class is pinned by neither: the OS packages four steps install withapt-get— the labeler's sandbox onrun-analytics, the same sandbox onintegration-test's labeler smoke, and the OCR recovery's two binaries onrun-repairand inrun-seed's credential-free OCR sweep parse job. They come from the runner image's own Ubuntu archive, with no third-party repository and no added signing key, so the trust is the runner's rather than ours; an exact version pin would only add a failure the week the image rolls, so each OCR step echoes the versions it resolved into its run summary instead. - Least-privilege permissions. Every workflow sets top-level
permissions: {}and grants only what each job needs. - No static key in the runner's process env where untrusted code runs. The
Claude and Codex engine actions proxy or scope their model API keys so those
CLIs never hold them. The engines this repo drives directly — Gemini
everywhere, and all three in the cert back-test and the integration suite's
engine smoke — are the exception: their key is a scoped step env on the agent
step, so the control there is the Gemini
CLI's own sanitizer, which strips every env var it has not been asked to
allowlist and refuses to allowlist any name matching
/TOKEN|SECRET|KEY|AUTH|CREDENTIAL|PRIVATE|CERT/i— so a model key can never reach the agent's shell. That strict mode is forced byGITHUB_SHA, i.e. in CI (the residual: off-CI there is no such barrier, which is a local dev run with the dev's own key). The lower-sensitivity CourtListener token is passed as a scoped step env in exactly two kinds of deterministic place, whatever the caller: the MCP sidecar composite's launch step, whose backgroundfedcourts mcp-serveprocess inherits it and serves the CourtListener MCP tools over localhost HTTP — the cells launch it, and so doesintegration-test— its engine-smoke codex leg, which exists to exercise that very wiring, its engine-actions-smoke legs, whose invocation blocks name the client config that sidecar serves, and each repro-family leg, which drives one of those blocks against a real record — and the collect job's aggregate step, where the secret scan (below) needs the live value to search the run's output for it — a step that parses agent bytes with jq/git/tested Python but never executes them. (Pull's ingestion holds the same secret under its own name; the two kinds here are the agent workflows'. A new caller of the composite is a new call site, never a new kind of place — the token reaches the launch step's env and stops there.) No agent step holds it, and no file an agent can read carries it: the client configs name only the sidecar'slocalhostURL, so no client-config file on a cell runner carries the token as a literal value for the agent's file tools to read. The cells have no REST fallback, so live CourtListener access is the MCP sidecar only (the agent calls it by tool name, never handling the token), and the token is never in the environment while an engine processes adversarial docket text. Residual blast radius if the token leaked despite this: it spends pull's quota and forces a rotation that touches pull — it is not a model key or a GitHub token. The remaining residual is process-level, and the output channel is gated. On-runner step-scoping is not hard isolation for the unsandboxed engines: on a claude or gemini cell the sidecar runs as the same user as the agent, so a determinedly-injected agent could still read a sibling process's environment — what the sidecar removes there is every casual path (no agent env, no readable config file, no accidental log). A codex cell runs the agent as a separate unprivileged account that cannot read the runner user's process environment at all, so even that determined read is closed for it; the residual holds only for the same-user engines. And the sidecar is deliberately unauthenticated on127.0.0.1: anything on the runner can spend the token's rate limits through it, which equals the agent's designed tool access — while off-runner use of the credential now requires that process-environment read, a strictly higher bar than the old read-the-config-file path. Should any secret reach agent output by any means, the exfiltration sink is gated: agent free text (reasoning.md, a rationale, a flag message) is exactly whatvalidatedeliberately does not read, so before anything is pushed thecollectjob runs a secret scan (fedcourts scan-diff-for-secrets) over the run's changed files and the PR prose about to be posted: literal containment of the live token in the cheap encodings (base64, hex, URL-escaping), credential-shape patterns, and an entropy heuristic — whose only candidate-level exemption is the collected run's own ledger paths (thepredictions//evaluations/layouts and the cell-relative<actor>/<run id>[/<file stem>]/<evaluator>/<predictor>/<run id>forms), named by--run-idwith the run id segment compared for equality and every free segment pinned lowercase and capped far below the heuristic's own minimum, so the skip can hide nothing the heuristic would convict standalone. That heuristic is off entirely, file by file, for the two files holding what a cell's tool calls carried —retrieval_log.json, which the harness captures, andretrieval.md, which the agent writes as its account of the same calls — because it would convict them wholesale: their content is document addresses, ids and search queries. Containment plus the credential shapes carry those two as they do an engine transcript's below. The residual is stated per file, since the two are not equally guarded. Inretrieval_log.jsonit is the 40-63 character window: capture-time redaction (below) reads every byte that file keeps on the way in and rewrites both the shapes it can name and any unknown run of 64 characters or more that scores like credential material, so what passes both layers is an opaque run shorter than that. Inretrieval.md, free text nothing rewrites, it is everything from 40 characters up that no shape names and no known credential matches. Every other file a cell writes — its reasoning, its evaluation, its flags — and the PR prose scanned beside them keep the heuristic. A hit withholds the branch — nothing is pushed and no PR opens; a redacted file/rule/line report (never the matched text) lands on the run's step summary and the files stay in the run's cell artifacts for maintainer review. The scan fails closed: if its token env is missing, the branch is likewise withheld, with a misconfiguration note on that summary in place of a findings report. The same command gates two surfaces outside a run branch on their own terms, both of them things theqp-topic-labelrun's agent wrote and neither of them validated by anything else. Its turn-by-turn engine transcript is scanned (--transcript-file) before it is uploaded as a run artifact, with every detector except the generic entropy heuristic — a transcript's server-generated tool and request ids are high-entropy by format, so that rule convicts every real file and the artifact could only ever publish empty. The partial labels the run wrote are scanned (--extra-file) before theirs, with the entropy rule left on: a labels line is case ids, docket numbers and vocabulary words, none of them high-entropy by format, so the reason to suppress it does not arise. (The labels the run publishes are a different object, gated byqp-topicsreading every row against the vocabulary and the extract's keys; this file is uploaded before that command runs, so it travels whether the gate refuses or passes.) Containment of the one credential the scan is given there — the engine's own API key — and the credential-shape patterns are the transcript surface's whole gate, which is why it fails closed the same way: a hit, or a scan that could not run at all, withholds the artifact, and the run's warning and step summary are the record. A third agent-written artifact publishes without this command: the unpaidqp-labeler-smokeleg's own labels file, which takes the containment half alone, by a literalgrepoff a PATH pinned to root-owned directories, because building the paid lane's fresh-checkout scanner there would mean running the agent's own workspace Python with the engine key in its environment — the arrangement that scanner exists to avoid (docs/security.md). The label-line count on that summary is arithmetic over the file rather than anything read out of it, which is why it is stated whether or not the file itself travels. Holding that key makes the scan's own import path part of the gate, and it follows an agent that writes freely in the tree the editable install resolves through — so the scanner is built from a checkout taken after the agent exits and fetched from GitHub, into a venv inside that fresh tree, with the package cache and the interpreter it is built on inside that tree too, and with the levers that put a caller's code inside a process it starts (PYTHONPATH,LD_PRELOAD, an executablecore.hooksPathin global git config) closed alongside. The same job's tree-pristine assertion is a separate control for a separate threat, a rigged measurement rather than a stolen key, and it gates the measure step rather than the capture: a tampered run keeps the agent-written evidence that diagnoses it. The toolchain that builds the scanner is held the same way:uvis checked against a digest recorded before the agent ran, since it sits where the runner user can rewrite it, and PATH is pinned to the root-owned directories sogitis the image's. What that isolation still trusts, named rather than implied: the action bundles the runner unpacks before a job's first step, which sit in a runner-user-writable path and are pinned by identity and not by bytes; the runner image's own files, behind the passwordless sudo the runner user holds; and anything a process the agent left behind does between steps. What it removes is the drop-a-file class on the scanner's Python import path and on the toolchain that builds it — the class an editable install hands over for free. A different surface is handled a layer earlier instead: the tool-call log the harness harvests from an engine transcript intoretrieval_log.jsonrecords whatever a tool call carried, which is not the agent's choice, so credential-shaped runs there are redacted at capture — rewritten to a[redacted:…]marker and the run allowed through, rather than costing a whole fan-out's model spend to a withheld branch. Redaction is not a gate: it spares only the shapes it can name, and what it leaves meets the scan's containment and credential shapes — though not, in that file, its generic entropy heuristic, which is the residual stated above. The scan is a heuristic and the cell's uploaded artifacts remain downloadable from the Actions run by logged-in users regardless, so the last line stays what it always was: the reachable secret is not worth stealing — the single-account, read-only CourtListener token whose worst case is spending pull's quota and forcing a rotation (above), not a model key. No App key and no write-scoped GitHub token is delivered to a cell: the Claude step gets a copy of the cell job's own token, capped atcontents: read, and a codex or gemini cell gets none. One credential stays in reach, and it is a residual rather than a control: the cell jobs holdid-token: writefor the read-only corpus role, so any process in the job can request an OIDC token for the job's environment, and every relying party that accepts that subject is reachable with it — the Claude GitHub App's installation-token exchange and any AWS role whose trust names that environment (S3 / the private stores in docs/security.md). - Agents get no App credential, never a static one. The Claude agent
steps in
run-predict/run-evaluatereceive the cell job's own short-lived token, capped atcontents: readby the job's permissions; the Codex and Gemini agent steps get no GitHub token at all — their blocked-channel isflags.json, surfaced by the trustedcollectjob. No job that runs an agent names an App's private key, since every secret a job names reaches its runner. The workflow'scollectjob (a distinctcontents: writeApp token) does the commit/PR, so a prompt injection in docket text cannot reach the dev App's token or its approval bypass. The job's OIDC request credential is the residual stated in the item above. Issue and docket text stay untrusted input. - The engine watchdog holds no credential. The engine hang bound
(
scripts/engine-watchdog.sh; Graceful degradation on limits in docs/pipeline.md) is trusted repo code that brackets every engine's cell step: one configured engine deadline per cell job, which every engine step'stimeout-minutesreads and which the watchdog fires a margin ahead of, so it can end a step's whole process tree before the runner's own timeout. Everything it records stays on the runner — its log, a marker, and a shapes-only diagnostics bundle — and leaves through the cell's own tail: the disarm step turns a marker into a::warning::and a step-summary line, records a deadline stop in the cell'sstatus.json, and carries the bundle into the cell artifact. That tail runs because acting is what concludes the step. So no step mints a token for it, its arm and disarm steps hold no GitHub credential, and the script makes no network call. - The hidden-record stash is forgeable by the same-user engines, and buys
nothing a cell does not already have. Predict and evaluate cells move the
committed
predictions/andevaluations/trees into a stash underRUNNER_TEMPfor the agent's run and restore them after. A claude or gemini agent can write that stash; a codex agent cannot (the codex setup strips other access fromRUNNER_TEMP). The restore shape-checks every manifest entry, keeps each destination inside the data root, refuses symlinks and never overwrites, and the cell artifact carries only the cell's own event directory, which the collect union adds without modifying committed bytes — so a planted file is at most an addition the agent could have written into its own event directory directly. - The reaper gives a cell one runner-local switch, and it is bounded by what a
cell already faces. The watchdog ends a step whose required output files all
exist, parse, and stop changing — so an agent can end its own step early by
writing them and idling, and a prompt injection in docket text can ask it to.
On a claude or gemini cell it can also simply create the watchdog's
REAPEDmarker: those agents run as the runner user unsandboxed (claudebypassPermissions, gemini--yolo), so no runner-local path is out of reach (the qp-topic labeler is the one agent held to a narrower grant, and it is not a cell). A codex cell cannot — codex runs as a separate unprivileged account, and the marker sits under a runner-ownedRUNNER_TEMPdirectory (mode 0755 and not writable by that account; the codex setup additionally strips other access fromRUNNER_TEMP), so this rests on that ownership, not on the uid alone. The runner-local marker is therefore forgeable only by the same-user engines. What either buys is narrow and worth stating exactly: the marker setsagent_ok, which routes the cell to the run's ready PR instead of the draft one, and nothing else.producedandvalidatedstill have to hold, the collect job still secret-scans and still enforces thedata/path jail, the cell's credential still carries nocontents: write, and a compliant agent could land the same bytes by finishing normally. The residual is that a cell which stopped early can present as one that finished — a claim about review routing, not a capability. The deadline markers (FIRED,STOOD_DOWN) are forgeable on the same terms, and a planted one buys less still: it sets the cell'sengine_deadlineinstatus.json, which only changes the reason the draft PR names for a stop — it never readies a cell. - No workflow creates a Release or holds a Zenodo credential. Release
archiving is Zenodo's GitHub integration: an OAuth grant on the maintainer's
account (webhook administration across the repositories that account
administers, no contents access) outside the Actions permission surface. A
published Release is archived permanently, so publishing one is a maintainer
step; any
contents: writecredential could create one, which is why no workflow does. Automating the dataset deposit would add a credential and changes this line. Setup and scope: docs/security.md, Zenodo archiving. - No static cloud keys — OIDC for S3. Workflows that touch the private S3
stores (the corpus remote and the per-case content store) assume a
least-privilege IAM role via GitHub OIDC. Three roles, split by access:
corpus writers get a read-write, append-only role (get/put/list, no
delete) and every corpus consumer a read-only role, so a compromised
consumer runner cannot tamper with the data. Three controls together mean no
run can wipe corpus objects: the buckets keep versioning on, the
write-role policy carries an explicit
Denyon every delete, and a secondDenycovers the two delete-equivalent bucket-configuration changes (versioning and lifecycle configuration) — a lifecycle expiration runs under S3's own principal, so a role able to install a rule could expire a whole prefix while its ownDeleteObjectcalls were still refused. The third role is the staging read-write role — read-only on the production stores, read-write on the staging corpus pair alone — so the production stores keep exactly one writer whatever happens to it. Every role's OIDC trust is scoped to named environments of this repo — the production read-write role toprod, the read-only role toprod,staging(which is what lets the pre-promotion integration runs read the corpus),corpus-checkandcorpus-check-auto(the PR corpus-check lane's approved and unapproved paths, under the trigger surface below), the staging read-write role tostaging— and each of those environments restricts deployments to its own branches (stagingalso admits the promotion train'spromote/*candidates: commits already onstaging, created only by the dev App and never moved), so a PR-branch job cannot assume any of them. Within an environment the split is provenance, not identity, on both sides of the promotion: anyprod-bound job could ask for the production write role and anystaging-bound job for the staging one, and what stops the wrong job asking is review of the code the branch policy admits — deliberately symmetric, so a wrongly-writing change fails visibly against the disposable staging pair before promotion instead of succeeding first in production. No committed file carries credentials or the bucket URL — each job (and operator) supplies the corpus address out of band — a base URL, one per environment, from which both stores' addresses derive (fedcourtsai.paths), or a store named on its own — and boto3 reads its credentials from the environment. Per-workflow role assignments and policies: docs/security.md. - No pipeline agent process holds a cloud credential. An agent runs over
third-party docket text, so no corpus credential reaches the process that
reads it — the one exception being an interactive Claude cloud session
started in the dedicated corpus environment, which holds a read-only key by
design and is bounded as the developer-access bullet below states — but the enforcement differs by job, and the guarantee is about the
process, not the step. In a predict/evaluate cell the read-only role's
credentials never enter the agent step at all: they ride the provisioning
and sidecar-launch steps as masked step outputs, and the agent reaches corpus
data only through the sidecar's
localhostquery surface.run-backtestassumes the role job-wide, so its agent step does carry the variables; there the shared runner seam spawns each engine CLI from a scrubbed base environment, dropping everyAWS_*name but the region and every credential-shaped name that is not the engine's own auth. The qp-topic labeling job needs neither, and assumes no role. Which mechanism covers which job, and the residual the back-test's shape leaves: Agent shells hold no cloud credential in docs/security.md. - The case-summary lane never holds its model key and a corpus credential
together.
summarizereads the corpus in itsplanandstagejobs (read-only role) and calls the model in itsgeneratejob, which assumes no role, holds noid-token, and fails before its first key-bearing step if an AWS credential or OIDC minting is reachable; the staged record crosses as a one-day run artifact, which on this public repository any signed-in user can download while it exists, and which the lane therefore fills only with supremecourt.gov content. The model call carries no tools, so the model reaches nothing but the record it is sent. The key is the bound environment's Anthropic key, held in this lane by the generate job alone and shared with the cells' spend limit, so every run spends only behind thereviewhold or the budgeted auto-release that routes a small round past it.tests/test_workflow_summarize.pypins the split; the lane's contract is docs/case-summaries.md. - One scoped exception: developer corpus access. Three developer flows,
all read-only, none in a repository secret or a commit. From Codespaces,
through user-scoped Codespaces secrets: the maintainer via IAM Identity
Center's short-lived SSO tokens — the role-assumed flow, whose read-only
role also reads the staging pair — and contributors via a dedicated
read-only IAM user provisioned on demand, scoped to the production stores
alone. From Claude cloud sessions, through the variables of one dedicated
personal cloud environment: a second, separate read-only IAM user, under a
production-only read policy that carries an explicit deny on writes and
deletes, and that a contributor user is given too. Those sessions act on
GitHub as the agent account, never with the maintainer's credentials. The
key is visible to the model and to every command in a session of that
environment, and Anthropic's environment configuration holds it. So a
prompt injection — in docket text the session reads, or in web content it
fetches — could print or send the key, and could also read stored corpus
content with the commands the session legitimately holds and move it out:
over the network, since the environment's default level reaches every
*.amazonaws.comhost and so any S3 bucket, and over GitHub, where the agent account can push branches and post public text. No control fences that egress; what bounds it is that the environment is chosen only for corpus work (discipline, not a control — any task can be started in it), AGENTS.md's rule that stored text never leaves the session, and the key's reach: it reads production only and can write nothing. It is rotated and revoked by its own runbook (docs/security.md). The exposure a leaked key of either user could buy is a republication of access-gated court data the pipeline does not itself republish (docs/data-sources.md), not a write: no developer principal can write or delete anything, and a billing alarm bounds egress abuse. See Developer access in docs/data-pipeline.md. - The trigger surface is the trust boundary, and the platform holds it. No
privileged workflow — one that mints a token, assumes a role, or runs an
agent — keys on an
issuesevent, or on any other trigger class an actor outside the write boundary can fire, so nothing an outside actor files reaches one at all. Every privileged lane enters one of two ways, and three platform facts cover both. Aschedulefires only from the default branch, so a cron can run only what a maintainer-approved promotion put onmain. Aworkflow_dispatchis gated by GitHub on repository write. And every privileged job binds a deployment environment whose branch policy pins the ref it may run from —prodtomain,stagingtostagingand the promotion train'spromote/*candidates (below),corpus-check,corpus-check-autoandwebsitetomain— so a dispatch from any other ref holds no role, no secret, no agent: a job naming an environment as a literal is refused at the deployment-branch gate before a step runs, and the three branch-resolving workflows (integration-test,run-analytics,summarize— the carve-outdocs/security.mddescribes) resolve an off-list ref to an empty auto-created environment the role trusts do not name, failing closed at the first credential instead. Repository write is wider than the people who should start a run, though — the agent account interactive sessions act as holds it — and GitHub lets every write holder dispatch and re-run. So every workflow that binds an environment, whatever its triggers, opens with the run gate, anauthorizejob with no environment, no secret and a read-only token that every other job of the run needs: a dispatch proceeds only when its dispatcher is a humanconfig/dispatchers.jsonnames or a pipeline bot it allows for that workflow; a re-run of any run only when its re-runner is such a human; a first attempt only for a dispatch or aschedule,pushorpull_request, every other event refusing. The agent account is deliberately absent from that file, and an unreadable file refuses everything. The gate's code is self-contained and owned: the job sparse-checks-outsrc/fedcourtsai/run_gate.py, which imports only the standard library, and the allow-list, and runs the module by path with the runner's Python in isolated mode, so no change to the package, its dependencies or its lockfile — none of them code-owned — can loosen it onstagingwithout the maintainer. Re-running some of a run's jobs keeps the gate's earlier success rather than re-running it, so every job's condition also requires the attempt the gate judged, or a re-runner on the list it published, and a closingrun-gatejob, which needs every job and so re-runs with any of them, fails a refused re-run rather than letting the earlier attempt's successes stand as a green run (tests/test_workflow_auth_gate.pypins all three). The gate holds only on refs whose workflow carries it, which is why the environment's branch policy stays the ref gate beneath it: a dispatch from a branch whose workflow drops the gate still reaches no role, no secret and no agent. What it does not cover: a refused run still occupies its workflow-level concurrency group while it fails (run-repair's corpus jobs pick their skip group instead) and stays in the run history;promoteandrun-opsstay ungated because they bind no environment and name no secret; and repository-level secrets, which a job reaches without an environment, are assumed absent (docs/security.mdkeeps every secret on an environment). Three jobs dispatch a lane themselves with the dispatch App, whose token holds Actions write (and PR read) and nothing else and whose key onlymain's code can mint:run-pull'schain-predictandrun-predict'schain, each starting an input-lessrun-predictonmain, and the promotion train'sdispatch-gate, starting the side-effect-freeintegration-testgatesuite at a frozenpromote/candidate, once per head. The allow-list admits the App for those two workflows alone. A dispatch from it enters through the same gates as a maintainer's and reaches the same code; the round it starts still waits on the review hold unless its own plan is within the auto-release caps and budget. The train's suite has no review hold: it spends staging's engine keys unattended — about one paid suite per candidate, outside the auto-release budget — bounded by the train's own daily cap (max_gate_dispatches_per_dayin the code-ownedconfig/promotion-train.yaml) and by never re-dispatching at a head. The pull window chains only on a forecast-relevant change, and neither chain job dispatches while a promotion is about to merge. Three workflows do take an outside-reachable trigger —ci.yml,lint-actions.ymlandcodeql.ymlonpull_request, which any fork contributor fires — and they are the shape that makes the rule readable rather than an exception to it: each carries top-levelpermissions: {}, binds no environment, names no secret, and mints no token or role, so a fork PR's run reaches nothing. Privilege and outside reachability are disjoint here, not merely rare together. One lane runs PR-head code with a credential, and it is the one place the environment's branch policy does not mean what it means elsewhere:corpus-check(docs/corpus-check.md) runs the check a same-repository PR by a listed author declares, at that PR's head, with the corpus read-only role. Its triggers are the same two — ascheduleand an input-lessworkflow_dispatch, so the workflow binding the environment is alwaysmain's — but the code it runs is not the ref's, so the branch policy is met only on paper. Four things bound it instead. Thecorpus-checkenvironment's required reviewer — the maintainer alone, no other user and no team: the maintainer approves a named head SHA and command, reading a diff pinned to that SHA, and the run then takes both from the plan, never re-reading the PR; the lane plans nothing, and the promotion gate refuses, while the environment lacks that reviewer or itsmain-only policy, since the read-only role's trust makes an unprotectedcorpus-checkenvironment that role for any write holder's branch workflow. The account: the head's install and the check run as a separate unprivileged account with no sudo, no cron orat, no unprivileged user namespaces and no route to the platform's metadata endpoints, which cannot read the runner's process environments, home, temp files, workspace or install root — each proven as the account before PR code runs — so the job's OIDC request pair, its runtime token, its step files and its log are out of the PR code's reach: it can mint no OIDC token for any audience (the Claude GitHub App's token exchange included), save no cache entry, upload no artifact. Every process the account owns is killed after each phase, and one that survives fails the run. A Linux account is not a sandbox, though: a kernel privilege escalation from that account reaches root and, through it, the runner's tokens. The approval and the read-only trust are why even that is bounded — the code ran because the maintainer approved it, and the most any token it could mint reaches is the read-only role. The trust: what the account holds is the read-only role's session, which reaches every store read-only and nothing it can write; and because the job itself holdsid-token: write, no role but the read-only one, in any account or cloud, and no other OIDC exchange may acceptenvironment:corpus-check, which holds that role, its region and the corpus address and no other credential. And the cache rule below. What stays open is network egress, the read-only session for the rest of its hour, and the kernel itself. The lane's invariant is precise: no PR-supplied code runs with the corpus credential without the maintainer's approval. One kind of request skips the approval because it supplies no code: a block that asks formain's code (at: main) and names auv run fedcourtssubcommand on the maintainer-owned auto allowlist (config/corpus-check.yaml: read-only queries that print counts and identifiers, each one's full option surface pinned by the maintainer-ownedtests/test_corpus_check_auto_surface.py, and the allowlist re-checked by the job itself before it runs) runs in a separate job binding the reviewer-lesscorpus-check-autoenvironment, whose tree is the run's own commit ofmain— the PR head is never checked out or fetched, and the parsed command line is all the job takes from the PR. That environment'smain-only branch policy is its control: the plan routes nothing to it while the policy is missing, the promotion gate refuses a promotion until it holds the policy and nothing but the read-only role's three variables, and the read-only role is the only role, and its trust the only OIDC exchange, that may acceptenvironment:corpus-check-auto. Every other request — any PR-head code, anyscripts/checks/script, any unlisted subcommand, any doubt about the allowlist or the environment — waits on the approval. Like every workflow that binds an environment, the lane opens with the run gate: the schedule passes it, and a dispatch or re-run proceeds only for a humanconfig/dispatchers.jsonnames — a re-run of the check also needs a fresh approval. The requester list (config/corpus-check.yaml) is maintainer-owned, and the lane's result comment comes from a separate job that runs no PR code, treats the run's outputs as untrusted, and records the posted body's sha256 in its own run summary — the check that tells a real result from one any write holder's workflow could post as the same bot.tests/test_workflow_auth_gate.pysweeps the whole workflow directory for a reachable trigger, pins the five privileged lanes to those two, and requires every job that mints a token, assumes a role, or runs an agent — directly or through a composite that does — to bind one of the branch-policied environments,corpus-checkandcorpus-check-autoadmitted for their own workflow alone — so a workflow added later inherits the boundary. Behind all of it thereviewhold remains the thing between a plan and any spend, on every round alike — save one bounded carve-out: a predict, evaluate or summarize round whose own plan is within the auto-release caps and budget (config/review-hold.yaml; any knob at 0 turns it off) binds the reviewer-lessreview-autoinstead. The back-test has no such carve-out. The per-round and daily caps bound estimated spend (the planning rates over the round's own minted matrix, or a summary plan's upper estimate); actual spend can exceed the estimate, since retries spend and the rates price some engines low. The trailing budget projects measured spend from the committed ledger, plus an in-flight allowance, plus the round's estimate, and must sit below thespendbackstop's ceiling or every round holds. Both read the collected ledger, so both are ex post in what they measure: they lag the rounds they bound, and the backstop stops the next round, never the current one. The knobs, the planning rates and the decision code are maintainer-owned (.github/CODEOWNERS). The daily cap counts the larger of two records, thereview-autodeployments and the auto-released commits onmain, across the three lanes. A write holder, which includes the agent account interactive sessions act as, can prune the deployment list or edit an unmerged collect PR, so each record alone is tamperable;main's history is not, once a round's commit has landed. The claude/codex agent steps onrun-predict/run-evaluateallow exactly two bot logins through their own actor checks (the claude action'sallowed_bots, the codex action'sallow-bot-users), pinned to the data App's and the dispatch App's own logins and never*. Those actions refuse a bot actor by default, and an unattended round's actor is not a requester at all — it is whatever GitHub attributes the cron to, which on a repo whose deterministic writers push to the default branch can be that login — so the grant is what keeps a round from dying on an attribution it does not control.tests/test_workflow_agent_bot.pylocks the pin in. It grants nothing a round could not already do: the round is running because a cron or a write-gated dispatch started it, and it spends only if a reviewer releases the hold or its plan is within the auto-release caps and budget. The allowance also names the dispatch App's login, since a chained predict round runs as that App;run-evaluateand the action-path smoke carry the same pair because the cells' invocation surface is held in lockstep. The App's token holdsactions: write, which could start any workflow; the two chain jobs that mint it start nothing butrun-predict, and the train's job nothing butintegration-testscenario=gateat a candidate, pinned bytests/test_workflow_dispatch_chain.py. The gemini steps andrun-backtestset no such allowance. - Branch protection and the deployment boundary.
mainrequires a PR passinggate,paths,promotion-gate, andmain-base; the data App is that ruleset's sole bypass actor, so the deterministic writer jobs (run-pull,run-seed,run-repair, andpromotion-record) push corpus facts and the promotion record straight tomainwhile everything agentic goes through that PR — enforced by identity, since the agent workflows authenticate as a separate dev App that bypasses no required check.main: require approvalrequires one approving review, which only themaintainersteam can give, and bypasses it for the dev App's pull requests (what lets its two unattended lanes land, by the jobs' own merge call — GitHub's auto-merge never exercises a bypass, and the call's doing so is unverified until its first production run; still bound by the checks), for the data App, and for any pull request an admin merges (how the maintainer's own lands, since GitHub does not let an author approve their own). So a promotion — opened by the dev App — merges on the maintainer's approval of its head: the promotion train executes that decision with the dev App's recorded bypass, merging a frozen candidate only after re-reading that an approverconfig/promotion-train.yamlnames approved exactly its head with nothing requested since, pinned to that head and still bound by the required checks. Otherwise it merges through a recorded bypass: the admin role's, or a dev App token with contents write, whose jobs merge nothing but their own lane's PR (or the approved candidate), run no agent, and change only by an approved promotion. The candidates live underpromote/**, which two branch rulesets let only the dev App create and nobody but the admin role move or delete. Code and config reachmainonly as a gated promotion batch fromstaging— except thepromotion-recordwriter's one-parent record commit, jailed byte-equal to its plan, touching onlysrc/fedcourtsai/process_registry.jsonanddocs/freeze-record.md— whose own ruleset requires a PR passinggateandpaths(sole bypass: the repository admin role, for the maintainer's deterministic sync push — no App bypasses it, and thesync-stagingworkflow's write token reaches the branch only through a PR that satisfies the same checks). On the paths.github/CODEOWNERSnames — the permission surface, this security posture and its authorization check, the promotion gate and lane machinery, and the agent configs — it also requires a code owner's review. That review binds every identity but the admin role. Interactive agent sessions act as a separate machine account,modelmirror-agent, which holds write through theagentsteam and no bypass on any ruleset, so both review rules bind them; a session that holds the maintainer's credentials instead holds the admin bypass, and for it they stayAGENTS.md's discipline. A ruleset with no bypass blocks force-pushes and branch deletion for everyone, so the committed history of the predictions, outcomes, and evaluations underdata/is immutable — every forward change is a new, attributable commit. Forward deletions of ledger records are confined to two bounded channels: the maintainer-reviewedcleanup/*PR lane, and the writer lanes' bounded repair sweeps — run-seed's attribution repairs, and run-repair's dispatch-gated removal of merits events whose docket carries no cert grant — each of whose CLI refuses to apply above a per-run blast-radius cap, and each of which stages its ledger deletions into the same commit as the corpus pointer they must match. Secrets and the two production S3 role ARNs live in theprodenvironment, whose deployment branches are restricted tomain: a workflow authored on a PR branch runs without them. A second environment,staging, is restricted to thestagingbranch and thepromote/*promotion candidates and holds the read-only role, its own engine keys for the pre-promotion integration runs, the staging read-write role. Awebsiteenvironment, restricted tomain, holds one third-party key, the email list's Buttondown key, for the one job that drafts the weekly public update (docs/security.md); the promotion gate's admin-read stage refuses while it admits anything butmain, holds anything but that key and the draft's tag id, or the key's name is also a repository or organization secret. A third,review, holds no secret and no role and admitsmainandstaging; beyond that its content is a required-reviewer rule, and it exists only as the audit-logged hold between a plan that would spend and the spend — run-predict, run-evaluate, run-backtest and summarize all bind it; one environment serves every spend hold rather than each minting its own. What each hold covers differs by what the trigger already gates: the two fan-outs and summarize put every run behind it, scheduled or dispatched — except a round auto-released within the auto-release caps and budget, which binds the reviewer-lessreview-auto, whose deployments are one of the shared daily cap's two counts (a summarize staging rehearsal never does:review-autoadmitsmainalone) — while run-backtest holds its scheduled fortnight and lets aworkflow_dispatchthrough, since a dispatch is a human choosing the parameters and itsengineinput defaults to the free offline stub. The promotion gate's admin-read stage verifies the rule is present, because an auto-created environment is unprotected and an unprotected hold releases instantly. Self-review is deliberately permitted: with a single maintainer the hold is a deliberateness gate — an explicit, audit-logged reading of the plan before the spend — not two-person control, and blocking the run's own actor would make a maintainer-dispatched run unreleasable. Revisit if a second maintainer joins. Thestagingenvironment also carries the staging read-write role's trust — the only credential in the system that can write a corpus store other than through theprodwriters, read-only on production and read-write on the staging bucket pair alone, so production's single-writer discipline is unchanged and the worst a staging-bound write can corrupt is the re-seedable fixture the refresh lane rebuilds in one dispatch. Until an admin deletes it, it also still holds the unusedfedcourtsai-stagingApp's client id and key, which no workflow references (The GitHub Apps in docs/security.md). - Prompt-injection awareness. Third-party text is untrusted input, and it is
the input a cell actually reads: the docket, the filed documents provisioned
under
record/documents/(party-authored), and — on an evaluate cell — the majority opinion staged atrecord/opinion/, which quotes whatever the parties put in front of the Court. No round takes a case list from anything a requester wrote, since the matrix is derived from committed corpus state. Agents are instructed to treat all of it as data, not instructions, and the cell's credential carries nocontents: write— its output rides tocollectas an artifact, so injected text cannot push code. - The ledger carries no agent instructions. Cells of every engine read files
under
data/, and Claude Code, Codex and gemini-cli each load instruction files (CLAUDE.md,AGENTS.md,GEMINI.md, …) or config directories (.claude/,.codex/,.gemini/, …) from the tree they work in. An added file with such a name, or any file under such a directory, at any depth and in any case, therefore fails thedata/path jail: producer-side incollect, which demotes the run PR to a draft, and again in the requiredpathscheck, which blocks its merge. A test pins that none exists underdata/; every other PR intomainmeets it in the requiredgate. The deterministic writers that push tomainwrite only code-derived file names, so they are safe by construction; the jail does not run on them, and the test catches a violation only after the fact, in the CI run onmainthat follows the push. persist-credentials: falseon read-only checkouts.- No credential-holding job restores a cache. An Actions cache is shared
across a repository's runs: any job can save an entry under a key nothing has
claimed yet, and a later job restoring that key runs what it holds. A job
that parses third-party content could therefore plant an entry, and so could
PR-head code in the
corpus-checklane were its account separation to fail. So every job installs throughsetup-python-envwith the cache off unlesstests/test_workflow_cache_restore.pyallows it, an allowed job is checked to hold no credential, no secret, no deployment environment and no write grant, and its result is no required check or gate evidence; the same test refuses every other cache mechanism in any workflow or composite, and that the repository root carries apackage.jsonasetup-nodewould cache from. A remote action's internal caching is outside what the test can read, so it is reviewed when the action's pin is added or bumped. - Secrets are never written to
data/or logs. Thevalidategate, the collect job's secret scan (which withholds a run branch rather than push secret-shaped content), and review on every agent PR are the backstops.
Please report security issues privately, not in a public issue — use GitHub private vulnerability reporting (Security → Report a vulnerability). We aim to acknowledge within a few days and ask for a reasonable window to remediate before public disclosure. Do not include privileged, sealed, or otherwise sensitive court material in a report.
These are experimental model predictions, not legal advice. Do not feed privileged or sealed material into the pipeline. For data terms, redistribution, and the PII stance, see docs/data-sources.md.