Implementation notes โ
Execution notes for the PM lane guard. Built 2026-08-24 as task 14 of that day's agenda, on feat/admin-and-merchant-portals.
How does it decide? โ
It tokenizes the command and judges a path only where it sits in a WRITE-TARGET position. โ
- A redirect target, a destination argument of a known write command, or a pathspec of a staging, commit, or restore call. A path anywhere else is a read, a pattern, or prose, and it passes.
- The tokenizer handles quotes, heredocs, redirect operators, file-descriptor numbers, and command segments. Heredoc bodies are dropped entirely: they are data being written to a file, never commands.
This is not the design a guard would naturally reach for, and that is the point. โ
- The obvious build is a regex over the raw command text looking for
apps/and friends. Both of this repo's known guard defects come from exactly that, and a path guard would hit them harder than either, because the PM's actual job is writing builder prompts that NAME the files a builder will touch.
| Defect | What it is | How this guard avoids it |
|---|---|---|
#945: A text-scanning guard cannot tell content from a pattern that matches content | The em dash guard blocked a command whose only em dash sat inside a grep pattern SEARCHING for em dashes. The issue's own conclusion: the question is role, not presence | A path in a grep pattern, a commit message, or an argument to a read tool is never in a write-target position |
The PR-ready door, written up in 2026-08-21_pr-ready-door_design.md | Its override check anchors on a command-segment boundary while grep reads a multiline command LINE BY LINE, so a heredoc body both trips the guard and, quoting the override, opens the door for the whole call | Heredoc bodies are not commands and never reach the matchers. The door is read off parsed leading env-assignments, scoped to one segment |
Inside the repo the lane is default-deny. โ
- Anything that is not
docs/projects/**is refused, rather than a blocklist ofapps/,services/, and the rest. A new top-level directory is covered the day it appears instead of the day someone remembers to extend a list.
git commit judges the staged set only when no pathspec is given. โ
- The operator runs parallel sessions against one working tree, so the index is SHARED. A pathspec commit takes only what it names, and judging the whole index there would refuse the PM for a builder's staged files.
- With no pathspec it reads
git diff --cached --name-only, which is the set that commit would actually take.-aadds the modified tracked set. This is the shape of the incident in thesafe-automationskill, where agit add -Aswept somebody else's rename into an unrelated commit.
Branch and working-tree maneuvers are left alone on purpose. โ
switch,stash,reset,rebase,merge, and a branch-movingcheckoutbelong toguard-git-branch.shand rule 17. Claiming them here as well would give one action two refusals naming two different rules and two different doors.- The one exception is
git checkout -- <paths>, a PATH RESTORE.guard-git-branch.shdeliberately waves those through because they are not a HEAD move, but a restore discards whatever a builder has in the working tree, so this guard takes it.
What does the concurrency question resolve to? โ
Nothing, and that is checked rather than assumed. โ
- The
safe-automationskill's defining question is what happens when three of these fire at once on the shared VM. This process mutates nothing: no lock, no cache, no temp file, no network, no migration. It reads stdin and runs read-only git plumbing. git diff --cachedandgit statustake no index lock, so a session mid-git addcannot make this hook fail or hang, and this hook cannot make theirs fail.
What did it cost? โ
About 49 ms per tool call, and about 4 ms when the hook leaves in bash. โ
- Measured over ten runs each on 2026-08-24. The node fork is the cost; the cheap exits happen in bash before anything forks.
- Two things take that cheap exit. The door, as measured. And, since the 2026-08-27 inversion (inversion.md), every session that has not declared itself the PM, which is nearly all of them: a builder now pays the 4 ms rather than the 49 ms on every Bash, Write and Edit call.
- That saving is the reason the declaration gate is duplicated in the hook as well as the decider, and each copy has its own test. They shadowed each other at first, so deleting either left the suite green.
- The hook is registered with a 10 second timeout, well above the measured cost.
What proves the tests are load-bearing? โ
Every mechanism was broken on purpose and watched go red, with disjoint red sets. โ
- A green suite proves nothing on its own. These are the mutations run against the shipped code, restored after each.
| Mutation | Tests that went red |
|---|---|
classifyPath always allows (the lane goes away) | 45, every blocking case |
classifyPath always blocks | 22, every allowing case that reaches a path |
| Heredoc bodies scanned as commands | 1, the builder prompt written by heredoc |
| The door honoured anywhere in the command | 3, segment scoping plus both laundering cases |
| A file-descriptor number treated as a path | 1, cp alongside 2>/dev/null |
| Long-form flag values treated as pathspecs | 1, git commit --message naming a blocked path |
sed treated as a write with or without -i | 1, sed -n as a read |
mv writes only its destination | 2, both directions of mv |
| The staged set never consulted | 1, git commit with a blocked path staged |
All of docs/ allowed rather than docs/projects/ | 1, the write to docs/engineering/ |
Two mutations came back green the first time, and both were the same trap. โ
- The
tddskill's warning that several mechanisms can produce one observable, so a test proves nothing about any one of them until the others are ruled out. - The file-descriptor case: the tokenizer drops the
2of2>&1, and a separate numeric filter drops the1. A test using only that shape passes with either deleted.cp docs/projects/p/README.md /tmp/x.md 2>/dev/nulltells them apart, because there the stray2would land as cp's destination. - The flag-value case:
-mis consumed twice over, by the value-flag list AND by the short-cluster rule. Onlygit commit --messageseparates them. - Both cases were added rather than explained away, and both then reddened exactly one test.
The strongest evidence was accidental. โ
- The guard blocked this project's own
git add, naming all four files, minutes after being registered. The commit went through only after the door was opened explicitly on the command line.
What does it knowingly NOT catch? โ
It fails open by design, so every gap below ALLOWS rather than blocks. โ
- Same posture as the other five guards. It is a tripwire against an agent that has talked itself into building, not a jail against a determined one. Each of these was probed on 2026-08-24 and the result recorded, rather than reasoned about.
| Shape | Probe | Why it is left |
|---|---|---|
| An interpreter writing a file | node -e ..., python -c ..., bash script.sh | Nothing on the command line says what the program writes. Catching it means running it |
| A package manager | npm install, which rewrites package-lock.json | Same reason. The lockfile is config, but no argument names it |
| A wrapper | xargs, find -exec, env, command | The write sits behind a level of indirection. guard-pr-ready.sh calls the same forms knowingly out of scope |
| An archive extraction | tar -xzf bundle.tgz -C apps | The paths live in the archive, not the command |
| A command substitution INSIDE double quotes | echo "$(rm apps/web/src/App.jsx)" | The tokenizer treats a double-quoted region as text. The same substitution UNQUOTED is caught, and was verified caught |
| Work arriving from elsewhere | git pull, git revert, git cherry-pick | These are branch maneuvers rather than authoring, and they belong to rule 17 territory |
| GitHub-side actions | gh pr merge, gh api mutations | Not writes to the tree. gh pr ready has its own guard, and merge is covered by rule 21 |
| A symlink pointing into the repo from outside | not probed | Paths are resolved lexically, so a symlinked route into a blocked tree reads as its outside location |
| Brace expansion in the COMMAND position | {rm,cp} -rf tooling/foo | The expansion decides which program runs, so the write command is not on the line either. The OPERAND form (rm -rf {tooling,apps}/foo) IS caught, see below |
| A path held in a variable | d=tooling/x; rm -rf "$d" | An unexpanded variable is not a path the guard can judge, so resolvePath returns null and the word passes. Same family as brace expansion: the shell produces the path, the command text does not carry it |
Brace expansion was not on this list, and that was the defect. Found 2026-08-30, branch review finding 2. โ
- It was a FAIL OPEN rather than a documented gap. The tokenizer's brace test was
/\s|$/, unanchored, and$matches at the end of every string, so the test was true for any input: a{at a word start ended the segment and discarded the operand behind it. The guard refusedrm -rf tooling/fooand allowedrm -rf {tooling,apps}/foo, which is the same command. - A gap list that is missing an entry is worse than no list, because the next reader treats the list as the boundary. This row exists so the reader knows which half of brace expansion is now judged and which half is not.
- The operand form is now expanded and every arm is judged, so one arm outside the lane refuses the whole command, while a brace list that stays inside the lane (
mkdir -p docs/projects/p/{design,harness}) passes. Before and after, with controls, infixes-0830.md. - The brace GROUP is a different shape wearing the same character.
{ rm -rf x; }opens a command list and still ends the segment, and it has its own cases in both directions so the anchor cannot be loosened again without a red test.
Two shapes are deliberately OVER-blocked, and the door is the answer. โ
patchandgit applyare refused wherever they point, including outside the repo, because their targets live in the diff rather than on the command line. Both are rare in a PM lane and both were verified refused.
What was found and NOT fixed? โ
The launcher gap was found, raised, and then closed in the same session. โ
- The finding: Write and Edit see only the environment the session started with, and shell state does not persist between Bash calls, so a session cannot export the variable for itself. The per-command inline form covers Bash only. That left the launcher as the only place a builder lane could be given permission, and left every builder one relaunch away from being stuck, which is the shape that gets a guard's battery pulled.
- Her call, 2026-08-24: the launcher does it.
lantern-fleet.shnow prefixes every non-PM lane's launch with the door, in the leading slot where a shell reads it as an assignment rather than as an argument. Same ordering lesson--remote-controlalready carries in that file. - A second finding came out of running it for real. The dry-run plan showed a
pm-sessionworktree, which a bare "everything except the window calledpm" rule would have opened, handing build permission to exactly the kind of session the guard exists to stop. Raised rather than decided, because drawing that boundary alone is the failure this whole day was about. Her call:pmandpm-*both stay closed, and nothing looser. - What was deliberately NOT done: the door is not in the shared
fleet-claude-args.sh, even though that file exists precisely so the two launchers cannot drift.pm-migrate.shonly ever launches thepmwindow, which must stay closed, so sharing here would open the one lane the guard is for. What those two launchers share is the ARGUMENT string, not the door.
The two defects in the sibling guards are still there. โ
#945fired three times on 2026-08-24 and the em dash guard is unchanged. The PR-ready door still both over-blocks prose and can be opened by it.- Neither was touched. They are separately filed, they need her decision, and an agent editing a permission gate that just blocked it is the exact shape the never-bypass rule exists to stop. What this project could do without a decision was avoid inheriting either defect, and pin the override-laundering one shut with a test so the next guard cannot pick it up.