Skip to content

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.
DefectWhat it isHow this guard avoids it
#945: A text-scanning guard cannot tell content from a pattern that matches contentThe 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 presenceA 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.mdIts 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 callHeredoc 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 of apps/, 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. -a adds the modified tracked set. This is the shape of the incident in the safe-automation skill, where a git add -A swept 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-moving checkout belong to guard-git-branch.sh and 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.sh deliberately 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-automation skill'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 --cached and git status take no index lock, so a session mid-git add cannot 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.
MutationTests that went red
classifyPath always allows (the lane goes away)45, every blocking case
classifyPath always blocks22, every allowing case that reaches a path
Heredoc bodies scanned as commands1, the builder prompt written by heredoc
The door honoured anywhere in the command3, segment scoping plus both laundering cases
A file-descriptor number treated as a path1, cp alongside 2>/dev/null
Long-form flag values treated as pathspecs1, git commit --message naming a blocked path
sed treated as a write with or without -i1, sed -n as a read
mv writes only its destination2, both directions of mv
The staged set never consulted1, 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 tdd skill'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 2 of 2>&1, and a separate numeric filter drops the 1. A test using only that shape passes with either deleted. cp docs/projects/p/README.md /tmp/x.md 2>/dev/null tells them apart, because there the stray 2 would land as cp's destination.
  • The flag-value case: -m is consumed twice over, by the value-flag list AND by the short-cluster rule. Only git commit --message separates 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.
ShapeProbeWhy it is left
An interpreter writing a filenode -e ..., python -c ..., bash script.shNothing on the command line says what the program writes. Catching it means running it
A package managernpm install, which rewrites package-lock.jsonSame reason. The lockfile is config, but no argument names it
A wrapperxargs, find -exec, env, commandThe write sits behind a level of indirection. guard-pr-ready.sh calls the same forms knowingly out of scope
An archive extractiontar -xzf bundle.tgz -C appsThe paths live in the archive, not the command
A command substitution INSIDE double quotesecho "$(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 elsewheregit pull, git revert, git cherry-pickThese are branch maneuvers rather than authoring, and they belong to rule 17 territory
GitHub-side actionsgh pr merge, gh api mutationsNot 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 outsidenot probedPaths 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/fooThe 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 variabled=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 refused rm -rf tooling/foo and allowed rm -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, in fixes-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. โ€‹

  • patch and git apply are 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.sh now 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-control already carries in that file.
  • A second finding came out of running it for real. The dry-run plan showed a pm-session worktree, which a bare "everything except the window called pm" 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: pm and pm-* 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.sh only ever launches the pm window, 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. โ€‹

  • #945 fired 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.

Built with VitePress