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.changedis 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:~49readsresearch/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.
- plain-decoy exploit still ALLOWs a records-only edit of
- AMG-4 (archive item 17) → STILL LIVE, aggravated. 05895ab's
--match-head-commitfixes TOCTOU only (which SHA gets merged), not who supplies the evaluator.auto-merge-records.yml:~44still checks out the PR merge ref and runs the PR's own guard copy. For same-repopull_requestevents the workflow file itself also runs from the merge ref with acontents: writetoken, 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_targetreading 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.