The mkdir lock around scripts/e2e-local.sh serialised the launch, not the run (the launcher returns with the server up), and its stale-holder path double- acquired in 4 of 91 measured races; the launcher's documented conflict is its per-worktree wwwroot, so slots now run on their own port and the lock is gone with its inventory row. process.orchestrated-session records the two scopings the harness needed: a rebase pushed with --force-with-lease as the one sanctioned rewrite, and the referee as the only agent that ticks Done-when boxes. Scripts: required-arg guard, per-issue claim probe, reviewer fetch recipe, codex fallback to a cold review-only agent with the substitution stated in the PR body, rebase before the review loop with a patch-id check at the push, non-interactive squash recipe, Land phase. README bullets re-parented; kickoff bullet keyed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
195 lines
42 KiB
Markdown
195 lines
42 KiB
Markdown
# 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 an `N/A` row that says "resolution and use are one step"
|
|
by the IDENTIFIER, not by the step count: `PINNED` requires 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 `die`s (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 unusable credential, an unverifiable answer (after retries) and an HTTP 200 whose body is not a manifest all FAIL the job. 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
|
|
|
|
1. **Unpinned dependencies are a different class and are not graded here.** `actions/checkout@v4`,
|
|
`docker/build-push-action@v6`, `mysql:8.4` and `renovate/renovate:43` are floating tags, and
|
|
`renovate.yml` runs 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 as `N/A` here records that
|
|
they were examined and classified, not that they are safe.
|
|
|
|
2. **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.
|
|
|
|
3. **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 explicit `N/A` rather 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,
|
|
so `prepush-rebase-check.sh` — which fetches `origin/main` and 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.
|
|
|
|
4. **Nothing here checks that a `PINNED` claim 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, as `docs/guard-inventory.md`.
|