# AMG-5 — the new sanctioned-move guard (47e8e73) fails OPEN three ways; one lets an unrecognized git file-status write into the owner's approval gate

status: open · raised panel run 13 (2026-08-24) · class: instrument-calibration (governance guard) · judgement: **YES** · effort 0.5 pd (guard **and** workflow) · horizon: live now, on any routine credential with contents-write

---

**Plain-language summary for an owner reading one paragraph.** The filesystem
approval gate that landed Friday (47e8e73 — `approved/` is now a directory no
agent may write to) has three fail-open holes I *executed* against the merged
code this run. The worst: a pull request whose file carries an unusual git
status (`changed`, GitHub's file-type-change status, e.g. a file turned into a
symlink — or any future/unknown status) is **allowed to write into your
`approved/` gate** and auto-merges, because the deny check only fires for four
named statuses and everything else falls through to the allow-list. The fix
inverts the logic to a strict allowlist and verifies moves against the real
rename pairing (which the workflow currently throws away before the guard sees
it).

## Mechanism — three legs, all reproduced against the guard at HEAD `05895ab`

Each `classify(...)` below was run in Python against the real
`scripts/automerge_guard.py`; the verdict shown is what the live guard returned.

**(a) Status-enum fail-open — the authority leg (most severe).**
`scripts/automerge_guard.py:~189` denies writes to `research/queue/approved/`
**only** when the file's status is in `("added", "modified", "renamed",
"copied")`. Any other status falls through to the pattern stage, where
`research/queue/approved/[^/]+\.md$` (`:~51`) is an **allowed** pattern.
- `classify(["research/queue/approved/item.md"], statuses={...: "changed"})` → **ALLOW** (`records-only; no gate touched`).
- Same with a fabricated status `"banana"` → **ALLOW**.
`changed` is a real GitHub PR-files status (regular-file ↔ symlink and other
type changes), so this is reachable, not hypothetical. This **writes into the
owner's approval gate** — an authority breach, in a guard whose own docstring
(`:~30`) claims it "fails CLOSED everywhere."

**(b) Basename-collision laundering — record-falsification.**
`_sanctioned_move` (`:~177-186`) recovers a rename's *source* by testing
basename membership in the flattened union file list, **not** the actual
rename pairing. A PR combining a legit `approved/foo.md → dismissed/foo.md`
with an illegit `open/foo.md → built/foo.md` (same basename) → **ALLOW**:
`built/foo.md` is blessed as an approved→built move because `approved/foo.md`
merely *appears somewhere* in the changed-file set. Fabricates
"approved-and-built" provenance for an item the owner never approved.

**(c) Bare addition to `built/` — record-falsification.**
`classify(["research/queue/built/new.md"], statuses={...: "added"})` →
**ALLOW** (`built/` is in the allow patterns and the approved-dir status check
does not cover it). So the commit-message invariant "open→built cannot skip
approval" holds only for *moves*; a bare add lands in `built/` unreviewed.
Companion: sanctioned moves never check blob content, so "approved contract
text is frozen until it moves out" permits arbitrary rewriting *during* the
move-out.

## Why one row, not three

A single fix closes all three and the legs share one function: invert to a
strict allowlist (**deny unless** status ∈ `("added","modified")` **or** a
sanctioned move verified against the *actual* previous→new pairing), and deny
bare additions to `built/`. Lead the row with leg (a) — it is the only one
that touches authority; (b)/(c) are record-falsification (git history retains
truth).

## Fix surface — guard **and** workflow (the candidate understated this)

The workflow destroys the rename pairing *before* the guard can use it:
`.github/workflows/auto-merge-records.yml:~65` flattens
`[.filename, (.previous_filename // empty)] | unique` into an **unordered
union** and `:~66` passes only `filename:status`. So `_sanctioned_move` cannot
verify against `previous_filename` today even if it wanted to. The fix must
also change the workflow to pass explicit `src→dst` pairs, and the guard to
consume them. State this in the PR: **two files touched.**

## Data plan
None — static code + synthetic `classify` fixtures. Owned.

## Test design + kill criterion (pre-registered, negative-controlled)
Headline deny-asserted control (the authority leg): `classify` of a write to
`approved/` with status `"changed"` **and** with a fabricated status must both
**DENY**. Plus: the two-move basename-collision PR → DENY; a bare `built/`
addition → DENY; a move whose blob content changed → DENY (if content-equality
is adopted). Clean controls that must still ALLOW: a plain addition to `open/`;
a genuine `approved/foo.md → built/foo.md` verified by real pairing; a plain
records edit. **Kill criterion:** all deny fixtures DENY and all clean
controls ALLOW post-fix; revert the guard/workflow change → the deny fixtures
red. If a leg proves unreachable in practice (e.g. GitHub never emits a status
outside the known set for a markdown file), record that as the finding and
keep the allowlist anyway — a guard that relies on an API enum staying fixed
is the defect.

## Cross-reference — status updates for archive items 16/17 (triage migrates these today)
Re-verified this run:
- **AMG-3 (archive item 16) → PARTIALLY CLOSED / defanged.** The fenced-real-gate
  + plain-decoy exploit **still ALLOWs** a records-only edit of
  `research/RESEARCH_QUEUE.md`, and the guard still parses that file's
  `## Approved`. But build-authority moved to the directory
  (`ops/RESEARCH_IMPLEMENTER.md:~49` reads `research/queue/approved/`), so the
  merged poison no longer drives the implementer. Cheapest full close: add
  `^research/RESEARCH_QUEUE\.md$` to the guard's deny patterns (a frozen
  archive needs no auto-mergeable edits) and delete the now-vestigial
  section-parsing. Fold this into AMG-5's fix if the owner wants the parser
  gone.
- **AMG-4 (archive item 17) → STILL LIVE, aggravated.** 05895ab's
  `--match-head-commit` fixes TOCTOU only (which SHA gets merged), not who
  supplies the evaluator. `auto-merge-records.yml:~44` still checks out the PR
  merge ref and runs the PR's own guard copy. For same-repo `pull_request`
  events the **workflow file itself** also runs from the merge ref with a
  `contents: write` token, and this plan has **no branch protection**
  (05895ab's own message states it), so base-ref-guarding the *script* is
  necessary-but-not-sufficient. Real closure needs a trusted execution context
  (`pull_request_target` reading the base-ref script, or branch protection /
  required review). AMG-4 stays its own row; AMG-5 does not subsume it.

---

## BUILT — 2026-09-02 (stacked on AMG-4, PR #127)

**Outcome: shipped, both files as the row demanded.** `scripts/automerge_guard.py`
is a strict allowlist: only `added`/`modified` proceed to the pattern stage; a
`renamed` entry proceeds only as a sanctioned queue move verified against the
REAL pairing (`previous_filename → filename`) the workflow now passes via
`--pairs`; a `removed` entry only as that move's source twin; a bare addition
to `built/` denies; every other status (`changed`, `copied`, `unchanged`,
anything unknown) denies. `auto-merge-records.yml` builds the pairing with jq
and hands it to the guard. Absent pairing = unverifiable = deny.

**Route — recorded honestly:** built on the owner's direct instruction in an
interactive session (2026-09-02 self-audit, finding M3), NOT via
`queue/approved/`; a human merges (the guard denies this open→built move).

**Kill criterion — met.** Deny fixtures deny: a write into `approved/` with
status `changed` and with a fabricated status; the two-move basename collision
(approved→dismissed + open→built sharing a basename); a bare `built/` add; a
rename with no pairing. Clean controls allow: a plain add to `open/`; a
genuine approved→built move with real pairing; a plain records edit; the
removed-source twin of a verified move. **Negative control:** the eight new
tests ran RED against the unfixed guard first (8 failed); with the fix
reverted to `origin/main`'s guard, the five deny/pairing tests red again
(7 failures incl. the pairing-argument ones); green with the fix.

**Deliberately NOT adopted, recorded:** blob-content equality on sanctioned
moves. The implementer's designed flow appends a `## BUILT` section DURING
the approved→built move, so "frozen until it moves out" content equality
would break the one legitimate move-out. The residual the row named stands:
a move-out may rewrite the contract text; git history retains the truth.
Also not folded in: AMG-3's suggestion to deny edits to the frozen
`RESEARCH_QUEUE.md` and delete the section parser — a separate decision.

---

*Provenance recorded by triage 2026-09-07 (annotation only; the record above is untouched): shipped in **PR #140** (`6321abb`, merged 2026-09-03). The built section above names only AMG-4's #127, on which this was stacked; #133 was the superseded duplicate and was closed unmerged.*
