`ETV_STATUS_AUTH` is gone from `test`, `migrations` and `functional-e2e`, and three same-subject sites were reworded to match. `review-verdict.yml`'s comment was the fourth and still named the symbol as a live thing; it now names the credential by what it is, and says the PR route materialises none to refuse. `docs/remote-state-inventory.md`'s row for `ci-toolchain-image-resolves.sh` listed "an unusable credential" among the shapes that fail the job — that script holds no credential any more. The row names the three refused-anonymous-read shapes the shipped script actually has instead, and re-confirms the `UNSAFE-KNOWN` grade against the anonymous script: the tag it reads is mutable either way. That is #909's first half; its other half, `docs/guard-inventory.md`, stays with the session holding that file. `ci.pr-route-carries-no-stored-credential`'s `mechanics:` carried one self-declared unmeasured claim — whether act_runner's daemon performs the credential-free `container:` pull. Measured 2026-09-05 on the runner host 192.168.1.99, which runs both act_runner containers and creates every job container on its own docker socket: a `docker pull` of the pinned tag with a scratch docker config holding only `{}` exits 0. The two things that run did not exercise — an anonymous layer download, and act_runner's own pull call path — replace the open unknown rather than being dropped, and `docs/ci-cd.md` cites both measurements. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
42 KiB
Remote-state inventory (ersatztv#778)
Every git-tracked file matching one of these, that reads live remote state and later acts on that read, and whether the read is bound to something that cannot change underneath it:
| Directory | Files |
|---|---|
scripts/ (recursive, excluding scripts/tests/) |
*.sh, *.py, *.jq |
.claude/hooks/ |
*.sh |
.husky/ |
all tracked files |
.gitea/workflows/ |
*.yml, *.yaml |
The per-directory extensions are stated because the prose once attached them to scripts/ alone
while the guard applied them everywhere, so a .py hook would have joined the scope the doc
described and acquired no row.
The scope qualifier is load-bearing, not throat-clearing: this file twice claimed to cover "every
executable in this repo" while its own derivation missed real remote readers, so the heading now
states exactly what the guard enforces — including the scripts/tests/ exclusion, so nobody adds a
remote-reading test executable expecting a red guard that will stay green. C#/TypeScript guards,
web/, and anything outside those directories are not covered. Scope is limit 3; the
files-not-call-sites limit is limit 2.
scripts/tests/test_remote_state_inventory.py derives the population from git ls-files and
asserts set equality against the Site column, so a new script that talks to a remote service
cannot ship without acquiring a row.
Read docs/decisions/records/process/check-and-use-pins-a-version.md before adding a row or
changing a classification.
Why this file exists rather than a linter
docs/defect-shapes-773.md §4 detector D is a fix pattern, not a mechanical detector: there is
no general lint for "this code should have pinned a sha." What makes the class actionable is that
the population is small and enumerable, so the detector is detector A — derive the population from
an authoritative source and assert set equality — applied to this inventory. The inventory is the
artifact; the test keeps it from rotting.
Columns
- Class —
PINNED— the read is bound to an immutable version identifier (a full commit sha, an image digest, a monotonic event count), and that binding still holds when the action runs. Two shapes qualify: either the action re-validates against the identifier immediately before committing (a snapshot nobody re-checks is not pinned — binding alone is never enough), or the check and the use are a single step over a value that cannot move, such as a workflow reading a full sha straight out of its own fixed event payload. What never qualifies is a value captured early and trusted later. The second shape is distinguished from anN/Arow that says "resolution and use are one step" by the IDENTIFIER, not by the step count:PINNEDrequires the value itself to be immutable (a full sha, a digest), while a one-step read of a MUTABLE identifier — a registry tag, a branch name — is not pinned and is graded on what it authorizes.CAS— the write itself carries a compare-and-set condition the server enforces.UNSAFE-KNOWN— read-then-act with nothing pinning it, accepted with the reason stated in the Note. Every row here must say why the residual is tolerable, not merely that it exists.N/A— reads no live remote state, or draws no authorization from what it reads.
- Note — the window, and what closes or bounds it.
A row is about a site, not a file: a file with two independent reads gets two rows only where the classifications differ; otherwise the strictest applies and the Note names the exception.
The inventory
Hooks
| Site | Class | Note |
|---|---|---|
.claude/hooks/pretooluse-merge-consent.sh — head-sha reads (CI status, H10 status, verdict comments) |
UNSAFE-KNOWN |
Every comparison uses the full 40-char .head.sha (${sha:0:7} appears only in human-readable strings), which is the right identifier — but it is captured ONCE from the PR snapshot at the top of the hook. It WAS stale in two distinct ways; the first is closed. FIRST, within the run: every later check (CI status, H10 status, verdict comments) was evaluated against the captured sha, so a push landing mid-run — including across the docs-only enumeration's up-to-forty round trips — was checked against the commit it replaced. Closed 2026-08-28 (#803), symmetrically with the base and at the same hoist: .head.sha is re-read from prjson_now, the response the base re-read already fetches, so the two axes cost one round trip between them and cannot describe two different instants; a moved head DENIES, matching the stale verdict class, while an unreadable one asks. SECOND, after the decision: the hook returns allow and a separate call performs the merge. "No async window" would be an overclaim. The window that remains is bounded the same way it always was — a head the verdict does not cover cannot inherit the sha-bound required status, so the server refuses it — and is not bounded by anything in this hook. Note the re-read narrows the window rather than erasing it: a push landing after the hoist still passes, and the hook deliberately does not re-read a second time (two reads move the window rather than closing it, the same rule the branch-protection block states). The merge API accepts an optional head_commit_id, which would make the call a true CAS; a PreToolUse hook cannot ADD that argument, only refuse without it, and requiring it changes every merge call's shape — tracked as follow-up rather than smuggled in here. Accepted meanwhile because the window is one tool call wide with no scheduler in it, and the server-side per-sha required check still refuses an unreviewed head. |
.claude/hooks/pretooluse-merge-consent.sh — scheduled auto-merge (merge_when_checks_succeed) |
UNSAFE-KNOWN |
Preflight, not a pin — the hook's own comment concedes the branch-protection read pins nothing, so calling it PINNED would contradict this file's definition. What the hook proves is a snapshot; Gitea merges later; and since #778 it also verifies that review-verdict/h10 is a required check, reading the repo's full rule list (never the by-name endpoint, which does no matching and knows nothing about precedence) — nothing can govern the base → deny, unreadable → ask, and a glob rule that could govern it → ask, because the hook deliberately does not reimplement Gitea's glob dialect. It also asks when two rule names fold equal, or when either name is non-ASCII, since Gitea's EqualFold is Unicode-aware and its rule precedence is not derivable here. That converts an unobserved assumption into an observed precondition and detects drift, but an admin can still weaken the protection after the read. Accepted. Calling the residual "closed one layer down" by the very branch protection an admin may have removed would be circular: it is not closed; it is BOUNDED, and the bound is a trust assumption that should be stated rather than dressed as a mechanism: everything on this path assumes repo-admin branch-protection config is not hostile. If protection is present at preflight and removed afterwards, an unreviewed head can merge, and nothing in this repo would detect it. What the check does buy is that the far commoner case — protection already weakened when the merge is attempted — stops being silent. A PreToolUse hook cannot add head_commit_id to the merge call, so it can never convert its own grant into a CAS — it can only refuse, which is what it now does. |
.claude/hooks/pretooluse-merge-consent.sh — base-retarget detection |
UNSAFE-KNOWN |
recorded_base parsed from the H10 status description is compared against the PR's live .base.ref (#632), and it deliberately compares the base ref rather than base.sha — the tip moves on every unrelated merge, so comparing it would deadlock every open PR. The residual is the same ABA the enumerator has: a name can be retargeted away and back, and the comparison cannot see it. Accepted because the alternative that CAN see it is the monotonic event count, which lives in the workflow that writes the enforced status rather than in this advisory hook. |
.claude/hooks/pretooluse-merge-consent.sh — ## Done-when issue-body read |
UNSAFE-KNOWN |
The issue body carries an updated_at that is not used, so a box unticked between the read and the merge is invisible. Accepted: the only actor who can edit the issue is the one requesting the merge, so this is a self-inflicted race with no adversary and no silent-failure mode. |
.claude/hooks/pretooluse-merge-consent.sh — guard-scope freshness branch_protections read (ersatztv#787) |
UNSAFE-KNOWN |
Shares ONE read with the row above since ersatztv#859, and is the only consumer on the IMMEDIATE merge path (that row fires only when arming a scheduled auto-merge). It was a second, separate GET of the same endpoint until then: on a scheduled merge the endpoint was hit twice, and — the part that mattered more than the round trip — the two reads of a MUTABLE config could return different answers, so the required-check arm and this one could decide about different repo states with neither able to notice. The payload is now fetched once per run and cached; the two arms still classify it independently, because they ask different questions of it ($base_ref's required contexts vs main's snapshot freshness) — the bytes are shared, never a verdict. Read-then-act with nothing pinning it — branch protection can change between this read and the merge Gitea performs. Accepted because of what the read AUTHORIZES, which is nothing: every outcome is ask or silence, never a grant and never a deny, so a stale or raced read can only cost a prompt. It is also skipped entirely unless the merge target is the repo .gitea/required-status-contexts.json names. The reconciliation it performs is bounded to merges made through this hook — a web-UI merge never runs it. |
.claude/hooks/prepush-donewhen.sh — ## Done-when issue-body read |
UNSAFE-KNOWN |
No pin, but the hook's exit code gates the push synchronously — git blocks on this process. Blast radius is near zero regardless: main carries enable_push: false and block_admin_merge_override: true, so the direct push this hook exists to block is refused server-side for every account (#743). This is belt-and-braces over a path the server already refuses. |
.claude/hooks/pretooluse-nav-guard.sh |
N/A |
Reads only the proposed tool call's own parameters and decides synchronously; the curl mentions in the file are prose, not executed lines. |
| .claude/hooks/prepush-rebase-check.sh — git fetch origin main, then git merge-base --is-ancestor origin/main HEAD to decide whether to block the push as behind | UNSAFE-KNOWN | Fetch-then-decide with no re-validation before the verdict; origin/main can advance inside that window. Accepted because the decision is self-correcting and cannot reach main: a push allowed on a now-stale read still lands on a feature branch, since direct pushes to main are refused server-side (#743), so the worst case is a rebase nag arriving one push later rather than a bad merge. This row exists because the previous token filter did not list git fetch and so could not see it at all. |
| .claude/hooks/prepush-clean-worktree-check.sh — git fetch origin main, then diffs origin/main...HEAD to scope which dirty files are in the pushed diff | UNSAFE-KNOWN | Same fetch-then-decide shape and the same bound: the verdict advises a feature-branch push only, main refuses direct pushes server-side (#743), and a stale origin/main read at worst lets a dirty-file push through, which the downstream review gate still catches before any merge. |
| .claude/hooks/decisions-guard.sh | N/A | Reads no live remote state — runs scripts/decisions_validate.py over the local working tree; no fetch, no HTTP call. |
| .claude/hooks/design-sync-reminder.sh | N/A | Reads no live remote state — compares local git diff/ls-files output against the session's own edits, never contacts origin. |
| .claude/hooks/posttooluse-worktree-marker.sh | N/A | Reads no live remote state — parses the tool call's own JSON payload and writes a local ownership marker. |
| .claude/hooks/pretooluse-agent-model.sh | N/A | Reads no live remote state — inspects only the proposed Agent call's own model/subagent_type fields. |
| .claude/hooks/pretooluse-agent-ram.sh | N/A | Reads no live remote state — samples local memory_pressure -Q output. |
| .claude/hooks/pretooluse-bash-guard.sh | N/A | Reads no live remote state — pattern-matches the proposed Bash command string for ETV_UPDATE_GOLDENS=. |
| .claude/hooks/pretooluse-bom-guard.sh | N/A | Reads no live remote state — inspects local git diff output and reads local .cs bytes for a BOM. |
| .claude/hooks/pretooluse-worktree-guard.sh | N/A | Reads no live remote state — reads a local .claude-worktree-owner marker in the target worktree. |
Husky git hooks
| Site | Class | Note |
|---|---|---|
.husky/pre-push |
N/A |
Delegates every remote read to prepush-donewhen.sh, prepush-rebase-check.sh and prepush-clean-worktree-check.sh, each of which carries its own row. This file forwards stdin ref lines and runs local npm run check:api/lint/typecheck/build against the checked-out tree. |
.husky/pre-commit |
N/A |
Reads no live remote state — local lint-staged, decisions-guard.sh, a git diff --cached scan, and local dotnet format --verify-no-changes. |
.husky/commit-msg |
N/A |
Reads no live remote state — greps the local commit-message file for a trailer. |
Scripts
| Site | Class | Note |
|---|---|---|
scripts/post-review-verdict.sh — commit-status write |
PINNED |
Re-reads the PR and compares both .head.sha and .base.ref immediately before the POST, and dies (exit 1, no status written) on a mismatch or on a field it cannot read. That last clause is load-bearing: guarding both comparisons with [ -n "$x" ] && alone makes a well-formed 2xx body that merely omits the field a no-op, and the status is posted having confirmed nothing. Regression-tested against the real predecessor (#778), since the redundant-looking -z arm alone mutates green. Closes #706 and #632 for this path by read-compare-refuse, not by CAS: Gitea's status API offers no conditional write. Residual closed on the write ORDER since #792: the status is written first and the comment second, so a refusal can no longer leave a verdict comment with no status behind it — the only reachable half-state is a status with no comment, which the merge hook resolves as ask (release.verdict-writes-status-before-comment). Since #845 the status is also READ BACK after the POST and refused unless it is the write just made, and — for a success only, mirroring the gate's own asymmetry — unless its .creator.login is on the allow-list derived from the gate (a failure is inherited from any attributable account, so requiring membership there would refuse a rejection the gate honours) — the same read-compare-refuse shape, applied to WHOSE verdict it is rather than to which commit it covers. That read is check-then-act too and is graded with this row rather than separately: it can only withhold this tool's success report, never authorize anything, and the residual it leaves — a non-inheritable status standing with no comment beside it — is the ask half-state above. Two properties of that read are recorded because neither is visible in the response. GET /commits/{sha}/status PAGES and its total_count reports the PAGE rather than the total (measured Gitea 1.27.1, 2026-08-29 — ?limit=2 on a 15-context head returned 2 rows and total_count: 2), so a truncated body is indistinguishable from a complete one. That looked like a risk of the verdict row falling off a page and is NOT, because of the second property: the endpoint selects each context's MAX row id and orders those DESCENDING before paginating (same measurement — ids [17,19,...,41,43], and ?limit=2 returns 41 and 43, the two highest). The row this tool has just POSTed is the newest on the head, so it sorts first and page 1 holds it at any page size. ?limit=100 (the instance clamps limit to MAX_RESPONSE_ITEMS, measured at 50) is kept as insurance against a burst of concurrent status writes and against that ordering changing, and has no observable effect otherwise — stated so it is not mistaken for an untested guard. A row it cannot find is therefore refused with the row count it read and no stated cause; an earlier version of this row named truncation as the candidate cause, before the ordering was measured. |
scripts/pr-changed-files.sh — paged file enumeration |
UNSAFE-KNOWN |
#707's fix: .base.ref, .base.sha and .head.sha are captured before paging and re-checked after, and any observed movement fails the whole enumeration closed rather than emitting a short list. But before-and-after equality is ABA-vulnerable — a main → scratch → main retarget during paging can return the same ref and, if nothing merged meanwhile, the same base sha, while the pages in between were diffed against the scratch base. The script's own comment says it narrows rather than erases — "any movement fails" would be stronger than the code. Accepted here because the enumerator cannot close it alone, but be exact about what the caller-side fence does and does not cover: ci.verdict-write-retarget-fence counts change_target_branch events, so it catches the BASE alias and nothing else. A HEAD alias — a force-push H1 -> H2 -> H1 during pagination — leaves the final .head.sha comparison equal while the middle pages were enumerated against H2, and NOTHING inside this script can see it. Fenced at the enforced caller since #803 (2026-08-28): the same timeline walk now also counts pull_push, so a head that moved at all during classification withholds the write — subject to that walk's own terminator, which is defeatable by a page of filtered rows — NARROWED, not closed, by #870 (2026-08-30): the walk no longer reads an empty page BEFORE the cap as the end of the list, but reads to its 20-page cap and requires the last page to be empty, so the same defeat now needs a timeline of more than 1000 rows rather than ~100, with the same 50-row filtered block pinned to offsets 950..999 — about 10x the price, the padding beneath the block being the attacker's to supply on their own PR. A cap filled with READABLE pages fails closed; a cap hidden behind a filtered block still certifies a too-low count. The grade stays UNSAFE-KNOWN for the SCRIPT, because the script's own guarantee is unchanged and one-way — a caller that does not run the fence inherits the alias. Enumeration bound to an immutable tree remains unavailable: re-probed at 1.27.1 on 2026-08-28, compare/{base}...{head} still returns no files key. |
scripts/select-queue.sh — issue list, then per-issue /dependencies |
UNSAFE-KNOWN |
The open-issue list (labels, milestone, priority) is snapshotted once; per-candidate dependency reads happen seconds later and never re-read the issue's own labels, so an issue claimed in-progress in that gap still appears on the shortlist. Accepted: the script authorizes no write. The real gate is the four-way claim check in process.parallel-session-claim, which runs after selection and re-reads live state by construction. Tightening this would move a check that must be adversarial into a tool that is advisory. |
scripts/ci-detect-already-validated.sh — prior-head combined status |
UNSAFE-KNOWN |
Reads the PR head's status and emits skip=true, with nothing re-checking before the consuming job runs. Accepted and narrow: the skip elides only re-running test/migrations on a tree already validated; the build job still builds and pushes unconditionally, so no image ever ships from unvalidated source. |
scripts/issue-qualification-audit.sh — paged issue list, then a report |
UNSAFE-KNOWN |
Pages the open-issue list and reports which issues lack a priority: label, so like every paged read here its pages can straddle a change and the report can name a state no single instant held. Graded to match select-queue.sh rather than N/A: the two run the same shape, and the reason offered for accepting select-queue.sh — it authorizes no write — cannot simultaneously be the reason this one is out of the class. Accepted on the same terms: it is advisory, session-end, human-read, and the labels it prompts for are applied by hand afterwards. |
scripts/security-scan.sh — docker pull, then docker run the same tag |
UNSAFE-KNOWN |
Pull and run are two steps over a MUTABLE tag, which is the same shape the registry rows below were graded down for; "resolution and use are one step" overstated it. In practice the second step resolves against the local daemon, which holds the image the pull just placed, so a mid-window retag does not change what runs. Accepted on that, plus the scope: this boots a throwaway container and scans it, authorizing nothing. |
scripts/migration-smoke.sh — docker pull, then docker run the same tag |
UNSAFE-KNOWN |
The same pull-then-run over a mutable tag as security-scan.sh above, and graded with it rather than left behind — spelled out rather than cross-referenced, because a backreference to another row's conclusion inverts silently when that row is regraded. This one deserves the grade MORE, not less — security-scan.sh boots a throwaway container and authorizes nothing, while this is the pre-deploy migration smoke that gates a production stack recreation. Accepted on the same bound (the run resolves against the local daemon holding the image the pull just placed) plus its own stated operator-trust gap: the resolved image id is reported for a human rather than compared against a prior read. |
scripts/hook-fire-log.sh |
N/A |
Entirely local: reads stdin and writes JSONL under the cache dir; the only curl in the file is in a comment. |
scripts/e2e-local.sh |
N/A |
No outbound call at all; readiness is a local log grep and a local port probe against a subprocess it started. |
scripts/e2e-ui.sh |
N/A |
Launches a local Chromium and runs specs against http://localhost:$PORT. |
scripts/e2e-functional.sh |
N/A |
Every call targets $BASE_URL, defaulting to http://localhost:8409. The one non-local-looking address, 192.0.2.1, is TEST-NET-1 (RFC 5737) — written into the DB as a connection row precisely so it is unroutable, never dialed by the script. |
| scripts/ci-detect-docs-only.sh — git fetch origin "$base", then diffs the fetched tip against HEAD to emit docs_only, which gates whether the required test/migrations jobs run their real steps | UNSAFE-KNOWN | Read-then-act with no re-check between the fetch and the emitted value, and the decision genuinely gates required CI work. Accepted because the script is deliberately asymmetric: every ambiguous, undeterminable or shallow-checkout case resolves to docs_only=false (run everything), and only an exact unanimous all-docs diff yields true — so a stale or racing base read can at worst cause an unnecessary full run, never a skipped one (#416). |
| scripts/refresh-shared-checkout.sh — git fetch origin main, then git merge --ff-only origin/main and a conditional npm ci | UNSAFE-KNOWN | Fetch-then-act with no re-check between the fetch and the merge. Accepted because every action is self-refusing or reversible: --ff-only fails harmlessly rather than diverging if the ref moved on, the script refuses outright when the tree is not clean main or is ahead or mid-rebase, and this is a developer-convenience checkout rather than a release or merge-authorization path — a stale read costs one extra fetch next run, never lost work. |
| scripts/lib/review-verdict-vocabulary.sh | N/A | Reads nothing at all — a sourced declaration of the H10 verdict word list plus pure functions over it; it makes no call, local or remote. Listed because it is in this inventory's scope (scripts/**/*.sh), not because it has a remote-state shape. |
| scripts/lib/h10-reviewers.sh | N/A | Reads no live remote state — it derives the gate's H10_REVIEWERS allow-list by parsing one anchored assignment out of the LOCAL .gitea/workflows/review-verdict.yml, and makes no call of its own. The read-then-act it feeds belongs to its caller's row above: post-review-verdict.sh compares the derived list against a creator it reads back from the server. Deliberately no env-var override of the file it reads — an env var choosing which tree answers a security question is an unsound input (#858). |
| scripts/check-review-verdict.sh | N/A | Reads no live remote state itself — classifies a comments JSON payload supplied on stdin; the fetch belongs to the caller's row. |
| scripts/check-required-contexts.sh | N/A | Reads no live remote state itself — classifies a branch_protections payload supplied on stdin; the fetch belongs to the caller's row (.claude/hooks/pretooluse-merge-consent.sh — guard-scope freshness). |
| scripts/lib/branch-rule-classifier.jq | N/A | Reads nothing at all — a jq program over a branch_protections payload its caller supplies, resolving which protection rule governs a branch. Loaded by both pretooluse-merge-consent.sh and check-required-contexts.sh; every fetch belongs to those callers' rows. |
| scripts/decisions_validate.py | N/A | Reads no live remote state — its git log/show/ls-tree/merge-base calls operate on refs the caller already checked out or passed via --base/--head, never a fetch. |
| scripts/prove-fix.sh | N/A | Reads no live remote state — git worktree add/rev-parse/diff-tree operate on the local repository's own objects. |
| scripts/add-migration.sh | N/A | Reads no live remote state — runs dotnet ef migrations add against local project files; implicit NuGet resolution is dependency supply-chain, out of this class per limit 1. |
| scripts/update-openapi.sh | N/A | Reads no live remote state — a local dotnet build/GenerateOpenApiDocuments then a local python script. |
| scripts/cleanup-code.sh | N/A | dotnet tool restore resolves and uses tooling in one step (limit 1, not a check-and-use split); the rest is a local git status --porcelain scan. |
| scripts/cleanup-all-code.sh | N/A | Same shape as cleanup-code.sh — restore-and-use in one step, no check-then-act over remote state. |
| scripts/build_decisions_catalog.py | N/A | Reads no live remote state — parses local decision records and writes the local catalog. |
| scripts/decisions_lib.py | N/A | Reads no live remote state — pure parser over local decision-record files. |
| scripts/migrate_decisions_split.py | N/A | Reads no live remote state — one-shot local file migration over docs/decisions/. |
| scripts/generate-endpoint-index.py | N/A | Reads no live remote state — reads local v1.json and writes a local markdown index. |
| scripts/check-doc-narrative.py | N/A | Reads no live remote state. Both modes are local: --diff reads git diff against a ref the CALLER fetched, --all reads git ls-files. The docs-reminder fetch that supplies the ref carries its own row below. |
| scripts/check-kickoff-guard.sh | N/A | Reads no live remote state — scans a fixed local file list for forbidden phrasing. |
| scripts/check-local-lsp.sh | N/A | Reads no live remote state — probes local PATH binaries and spawns a local MCP server over stdio. |
| scripts/mcp_smoke.py | N/A | Reads no live remote state — spawns a local subprocess and speaks JSON-RPC over stdio pipes, no network socket. |
| scripts/jq-preflight.sh | N/A | Reads no live remote state — runs local jq --version. |
| scripts/ci-peak-anon.sh | N/A | Reads no live remote state — samples the runner's local cgroup memory.stat/memory.peak. |
| scripts/ci-prove-ban-detects.sh | N/A | Reads no live remote state — mutates a local workflow copy and runs pytest against the local checkout. |
| scripts/ci-step-ran.sh | N/A | Reads no live remote state — reads runner-supplied env vars and local marker files it wrote itself. |
| scripts/ci-toolchain-image-resolves.sh — registry manifest read for the pinned toolchain tag | UNSAFE-KNOWN | Reads a MUTABLE identifier (a registry tag) with nothing re-checking it before the container: jobs pull, so a tag deleted between the preflight and the pull is reported as present. Graded UNSAFE-KNOWN rather than N/A deliberately: nothing proceeds on the strength of the read — it can only turn its own job red, which is not nothing (the merge-consent hook denies on the COMBINED status, ersatztv#598) but is not authorization either — while a stale PASS is read by a human as "the image is fine", which is an assertion about remote state this file exists to grade. The residual is bounded by what it degrades to: a stale pass leaves exactly the pre-#772 behaviour (five jobs failing at pull), never anything that proceeds on the strength of the read. The opposite error is closed by the EXIT CODE rather than by wording: an unverifiable answer (after retries), an HTTP 200 whose body is not a manifest, and all three shapes of refused anonymous read — a 401/403 surviving a bearer the run actually obtained, a 401 whose token leg produced no bearer, and a first-read 403 that never entered the token leg at all — every one of them FAILS the job. The script names no credential (ersatztv#885, ci.pr-route-carries-no-stored-credential): it runs on the pull_request route, where the YAML is head-supplied, so it reads this public package through the registry's anonymous Bearer-token flow instead. That changes nothing about the grade — the tag it reads is mutable either way. THE TRAP, since warning on those and exiting 0 is the natural way to write this check: a missing curl, a moved registry or a DNS change all land there, and a green-with-a-warning job is indistinguishable from a healthy pin forever after — "the check could not run" presenting as "the pin is fine", which is precisely what this row would then be asserting falsely. |
| scripts/set-provider.sh | N/A | Reads no live remote state — sets local dotnet user-secrets values. |
| scripts/__init__.py | N/A | Empty package marker — executes nothing. |
| scripts/scripted-schedules/entrypoint.py — ScriptedScheduleApi.get_context(build_id), then define_content / reset_playout / build_playout against the same live server | UNSAFE-KNOWN | A genuine read-then-act over live ErsatzTV state. The context is fetched, handed to user-supplied script functions that mutate the playout, and re-fetched after a reset with nothing pinning either read — a concurrent build or edit between them is invisible. Accepted because it runs inside a single scripted-schedule build the server itself serialises per playout, and because the API exposes no version or ETag on the context to compare against; the honest bound is that the blast radius is one playout's content, reversible by rebuilding. |
| scripts/macOS/bundle.sh | N/A | Reads no live remote state — moves files and creates symlinks in a local app bundle. |
| scripts/macOS/sign.sh | N/A | codesign --timestamp contacts Apple's timestamp server, but resolution and use are one step with no earlier check whose answer is later trusted — the same boundary as limit 1. |
| scripts/macOS/sign-dmg.sh | N/A | Same shape as sign.sh — a timestamped codesign over a local DMG, no check-then-act over remote state. |
Workflows
| Site | Class | Note |
|---|---|---|
.gitea/workflows/review-verdict.yml — status read → status POST |
UNSAFE-KNOWN |
The residual this whole class reduces to. Gitea's status API has no ETag, no If-Match and no expected-previous-state, so read and write cannot be made one operation. Narrowed rather than claimed closed: a monotonic event-count fence refuses to write its CLASSIFICATION if the PR timeline's change_target_branch OR pull_push count moved — and since #849 marks the head with the unverified-write sentinel when it carries a row that run did not inherit (ci.verdict-write-retarget-fence; counts are used because the branch name and the head sha are both ABA-vulnerable — the head axis added by #803), and a high-water-mark re-read repairs a status posted over a human verdict back to pending — after EVERY write since #849, not only an exemption success, because a generic pending masks a rejection just as well and was then re-derived green by the next run. Since #849 the write side is also fenced on the far end: the retarget count is re-taken AFTER the POST on the exemption path (closing the permanent forged green that record listed as residual 1), a write no high-water mark can cover becomes the sticky sentinel rather than a status a later run re-derives, and every path that cannot establish what the head carries — the combined read, the four page-2 completeness refusals, and an untrusted fence holding a derived success — REPLACES that unknown state instead of leaving a possibly-forged green standing (ci.verdict-unverified-write-sentinel). The post-POST re-count makes the retarget green TRANSIENT, not absent: it is live between its POST and the repair. The file states the residual window explicitly rather than asserting safety. |
.gitea/workflows/review-verdict.yml — base-ref checkout |
PINNED |
ref: ${{ github.event.pull_request.base.sha }} — a full sha from the fixed event payload, so the PR head cannot supply the workflow definition that judges it. |
.gitea/workflows/review-verdict.yml — changed-file enumeration |
UNSAFE-KNOWN |
Delegates to scripts/pr-changed-files.sh with the head sha and expected base, and therefore inherits that row's residual, not a pin — a cross-reference to another row's GRADE goes stale the moment that row is regraded, so this one names what it inherits. Accepted on better terms than the enumerator alone — this is the one caller that also runs the monotonic event-count fence (ci.verdict-write-retarget-fence), and since #803 that fence covers BOTH axes: change_target_branch for the base alias and pull_push for the HEAD alias described in the enumerator's row (H1 -> H2 -> H1 during pagination). What remains is the residual the fence shares with the base axis — a mutation landing between the final pre-write count and the POST — not an unwatched axis, and since #849 the RETARGET half of it is caught by a post-POST re-count while the push half is deliberately not (a push moves the head, so the status no longer gates that PR). |
.gitea/workflows/docker-build.yml — CI toolchain image |
UNSAFE-KNOWN |
This file's own definition of PINNED names an image digest, and ersatztv-ci:<short-sha> is a mutable tag. A registry tag can be repointed after ci-image-pin verifies it and before a job pulls it, and a 7-hex short sha is additionally collision-prone. Accepted rather than fixed here because the exposure needs write access to our own LAN registry — i.e. an attacker already inside the trust boundary — and two controls bound it: jobs never consume :latest, and pr-checks.yml's ci-image-pin fails the build if the tag drifts from the last commit touching docker/ci/. Consuming image@sha256:… is the real fix and is the natural companion to #772, which already covers the availability half of this tag's weakness. |
.gitea/workflows/docker-build.yml — release smoke pull |
UNSAFE-KNOWN |
Pulls ${IMAGE}:${SMOKE_SHORT_SHA}, the tag this same job pushed moments earlier. The job's concurrency group does not make that a pin: the group is per-ref, so a branch build and a tag build of the same commit sit in DIFFERENT groups and can publish the same :<short-sha> — the smoke step can therefore pull the other run's image. Accepted rather than fixed here because the fix is the same one-line change as the row above (pull image@sha256:…, propagated from the push step) and belongs with it; until then no registry row in this file claims to be pinned. |
.gitea/workflows/docker-build.yml — api-docs / format base fetch |
UNSAFE-KNOWN |
Fetches the live base tip to diff generated artifacts, with nothing pinning it. Grading this N/A on "advisory" is too quick: these are not branch-protection-required contexts, but the merge-consent hook reads Gitea's combined status, and a combined state that is not success blocks the auto-grant — so a wrong answer here does participate in merge consent. Accepted because the failure direction is benign: a base that advanced mid-job makes a generated artifact look stale and FAILS the job, costing a re-run, rather than passing something it should not. |
.gitea/workflows/ci-image.yml — verify the pushed image |
UNSAFE-KNOWN |
Pulls back the :<sha> tag it pushed in the immediately preceding step. Same demotion and same reason as the two rows above: a registry tag is not a digest, and anything able to write to the registry can repoint it between the push and the verify, which would make the verification confirm an image other than the one built. Accepted on the same terms — the exposure requires registry write access, i.e. an actor already inside the trust boundary — and the same fix applies. |
.gitea/workflows/pr-checks.yml — prove-fix |
PINNED |
base/head are full shas from the event payload, immune to later PR mutation. |
.gitea/workflows/pr-checks.yml — ci-image-pin |
N/A |
A purely local comparison — git log over the checked-out tree against a literal in docker-build.yml. It never queries the registry, which is exactly why it cannot bound the retagging the image rows above describe. |
.gitea/workflows/pr-checks.yml — docs-reminder base fetch |
N/A |
Fetches the live base tip, but the result only selects the text of a non-blocking warning. The rationale has to be narrower than "the job cannot fail and never reaches the combined status", which is false — any job's status joins the combined state, and runner or checkout failure can redden it. What is true, and is what earns the N/A: the fetch and diff are failure-swallowed, so the remote read cannot change this job's outcome, only the warning's wording. It draws no authorization from what it reads, this file's second N/A clause. Grouping it with decisions-guard over-demoted it, and the two have different classifications, so per the rule above they get separate rows. Since ersatztv#784 the job runs TWO advisory checks off this fetch (the parity-doc reminder and scripts/check-doc-narrative.py); the classification is unchanged because the second is advisory on the same terms — it exits 0 on every path, so a moved base still only changes warning text (its unproven arms are enumerated in docs.no-session-narrative). |
.gitea/workflows/pr-checks.yml — decisions-guard base fetch |
UNSAFE-KNOWN |
Fetches the live base tip and diffs against it, unpinned, and unlike docs-reminder this job CAN fail — so its answer reaches the combined status the merge hook reads and therefore participates in consent. Accepted on the same grounds as the api-docs/format row: a base that moved mid-job produces a spurious FAILURE and a re-run, never a spurious pass, and it re-fetches fresh on every trigger with no state carried between runs. |
.gitea/workflows/renovate.yml — renovate/renovate:43 |
N/A |
Out of this class, in scope for a different one — see the limits below. |
.gitea/workflows/dependency-scan.yml — NuGet advisory query |
N/A |
dotnet list package --vulnerable queries a live advisory database and fails the job on the report marker in the same step, so there is no check-then-act split. Worth one line anyway: a green here means "no advisories as of this run", which is a dated claim about mutable remote data rather than a property of the tree — which is why the scan runs on a weekly schedule instead of only on PRs. |
Limits, stated rather than implied
-
Unpinned dependencies are a different class and are not graded here.
actions/checkout@v4,docker/build-push-action@v6,mysql:8.4andrenovate/renovate:43are floating tags, andrenovate.ymlruns its one with a repo-writing token. But resolution and execution are the same step — there is no earlier check whose answer a later action trusts — so they are a supply-chain-pinning concern, not a check-and-use race. Listing them asN/Ahere records that they were examined and classified, not that they are safe. -
The population is files, not call sites. The test derives which files are in scope; it cannot tell that an existing file grew a second, unpinned read. That residue is the sites-in-code limit named in
testing.guard-derives-population-from-source— it needs find-all-references tooling, tracked in #777 — and it is why the Note column is prose a reviewer reads rather than a field a script checks. -
Scope is hand-written; the population inside it is derived, with no content filter at all. Scope is every file under
scripts/(*.sh,*.py,*.jq),.claude/hooks/,.husky/and.gitea/workflows/— a reviewed policy choice. Inside it, every file gets a row, and a file that reads no remote state earns an explicitN/Arather than silently staying out.The first version filtered that scope by an outbound-network token list and argued the filter was a scope choice rather than a population filter. That distinction does not survive the evidence: the list omitted
git fetch, which is this repo's most common remote read, soprepush-rebase-check.sh— which fetchesorigin/mainand derives a push decision from it — was structurally invisible to a guard claiming to cover "every executable that reads live remote state", along with three others. The defence offered was that over-inclusion is the safe direction; the filter also under-included, which is the direction that costs a blind spot. Enumerating the directories costs more rows and has none. -
Nothing here checks that a
PINNEDclaim is true. The test asserts every in-scope file has a row and that the classifications come from a closed vocabulary. Whether a row is honest stays a review responsibility, and this table is what review reads — the same split, and the same admitted residue, asdocs/guard-inventory.md.