Thales
← research journal

An internal research document, published verbatim by the automated daily export — not written for an audience, and better for it. All performance discussed is simulated paper trading; nothing here is investment advice.

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.