Em Dash Guard Boundary โ
- Status: done, opened 2026-08-24; reopened and corrected 2026-08-30 (the boundary was described more narrowly than the guard's glob actually is).
- Issue:
#967: key-conventions.md said the em dash guard never sees a heredoc; it blocked one twice today - Launch plan: none, this is tooling.
What is this? โ
A same-day correction to a wrong claim in .agents/rules/project/key-conventions.md about what guard-em-dash.sh actually covers. โ
- The rule said the hook covers only Write and Edit, and never sees a file written through a shell heredoc. That is false: the
.claude/settings.jsonmatcher isWrite|Edit|Bash, and the Bash branch scans any command whose text CONTAINSgitorgh(see the correction below: that is a substring glob, not a check on which program runs). A heredoc feeding agit commitmessage or aghPR body IS caught, which is exactly why a dispatched agent got blocked twice today on that pattern, before ever touching a file. Three sessions read the wrong sentence, treated heredoc writes as fully unguarded, and a compensating control got built on that false premise.
What is the current state? โ
Fixed, tested, committed, and pushed. Commit cf910262 on feat/admin-and-merchant-portals. โ
- Regenerated the four synced copies via
npm run sync:agents(AGENTS.md,.cursor/rules/key-conventions.mdc,.github/instructions/key-conventions.instructions.md).GEMINI.mdneeded no change since it only importsAGENTS.md. npm run sync:agents -- --checkconfirmed no residual drift after the regeneration.- Verified the em dash count stayed at 0 (baseline) on every file touched, before and after.
CORRECTION, 2026-08-30: the gate is a SUBSTRING GLOB, not a check on which program runs. โ
- The line below said the Bash branch fires "only on a command whose text names
gitorgh". The guard's actual matcher iscase "$cmd" in *git\ *|*gh\ *|git\ *|gh\ *), four globs over the whole command string. Any command containingghorgitANYWHERE satisfies it, which ordinary English does constantly. - Verified against the live hook on 2026-08-30, same technique as the original ten payloads:
echo "read it through <dash> carefully"BLOCKED,highBLOCKED,enoughBLOCKED,digitBLOCKED (that one is thegitglob). A plainechowith none of those substrings is still ALLOWED, andlight<dash>houseis ALLOWED because theghthere is not followed by a space. - Nothing needs loosening. The error runs in the safe direction: the guard scans MORE commands than the docs claimed, never fewer, so no em dash slips through a gap this misdescription hid. What it changes is the reader's model, which is why it is worth correcting rather than shrugging at.
- Why the original pass missed it: the ten payloads all tested the content half of the boundary (a dash arriving by variable, by substitution, by
git checkout) against commands that really weregitcommands. None tested a NON-git command that happens to contain the substring, so the glob never got to disagree with the prose.
What the boundary actually is, established by testing before writing anything. โ
- The Bash branch of
guard-em-dash.shfires on a command whose text containsgitorgh. Anything else (a plainecho,cat, or heredoc with neither substring) is never scanned, regardless of what it contains. - Even a matching command is only scanned as literal text. It cannot see content that arrives without being spelled out there.
- The corrected sentence now in
key-conventions.md: "The hook also scans Bash commands, so a heredoc feeding a commit message or PR body is caught before it lands. Its gate is a SUBSTRING glob over the whole command text (gitorghoccurring anywhere in it), NOT a check that the command runsgitorgh:through,high,enoughanddigitall satisfy it and all get scanned, verified against the live hook on 2026-08-30. That errs safe, scanning more commands than it needs to rather than fewer, so nothing wants loosening. What it cannot do is see past the literal text, so an em dash reaches disk unscanned whenever it arrives some other way: a heredoc to a plain file, a shell variable, a copied or moved file, a program's own output, or content agit checkout/git applyrestores. CI catches that path at PR time."
Tested vs. reasoned, named explicitly. โ
- Tested, by piping ten crafted JSON payloads directly into the live
.claude/hooks/guard-em-dash.shscript (the same technique its own committed test suite uses) and reading the real exit code:- Literal dash in a
git commitcommand text: BLOCKED (control, matches the committed test suite). - Literal dash in a non-git
echo: ALLOWED (control, matches the committed test suite'sleaves non-git commands alonecase). Only because that particularechocarried neither substring, see the 2026-08-30 correction above. - A
git logwhose format string is built by command substitution ($(python3 -c ...)), dash never literal in the command text: ALLOWED. - A
git commit -m "$VAR"referencing a shell variable, dash never literal in the command text: ALLOWED. git checkout HEAD -- file, restoring tracked content that has a dash, no dash in the command text itself: ALLOWED.git apply, restoring patched content: ALLOWED.cpandmvof a file whose content has a dash, no git/gh in the command at all: ALLOWED (both).- A program's own output redirected to a file (
python3 -c "print(chr(8212))" > file), no git/gh in the command: ALLOWED. - A
git commitwhose message is generated by a subprocess via command substitution: ALLOWED.
- Literal dash in a
- Reasoned, not tested: whether a literal em dash inside a non-git heredoc (e.g.
cat > file.txt <<EOFwith the character typed directly in the body) would be scanned. Could not test this directly without typing a literal em dash into a command, which this task's own hard constraint forbade. Confirmed instead by reading the guard's source (thegit/ghsubstring gate does not distinguish a heredoc body from any other command shape) and by the committed test suite'secho ${DASH}case, which exercises the identical code path. - Attempted and deliberately abandoned: a live end-to-end proof in an isolated scratch git repo (write a file with a real dash outside git, commit it, then
git checkoutan older revision to restore it, and grep the result). Theguard-pm-lane.shhook refused the command because it flagged a barefile.txtwrite-target token, even though the whole thing lived under/tmp/.../scratchpad/. This session'sCLAUDE_ALLOW_PM_BUILD=1authorization was scoped explicitly tokey-conventions.mdand the sync output, nothing else, so the override was not used to push past it. The ten direct hook-script invocations above already exercise the real guard code path and were treated as sufficient without it.
A second, unrelated guard also fired, and is worth recording. โ
- The first commit attempt used the standard
git commit -m "$(cat <<'EOF' ... EOF)"heredoc pattern.guard-backtick-body.shblocked it: live$( )substitution inside a double-quoted-margument is treated as a shell-injection shape (a real incident on 2026-08-13 executed agit pushthis way), regardless of how clean the content inside is. Its own message names the fix: write the message to a file with a quoted heredoc, then use-F. Re-ran that way; the pre-verified, em-dash-checked message file was used directly withgit commit -F. - Also found mid-task: another session is concurrently modifying
.github/workflows/ai-changelog.ymland.github/workflows/discord-notify.ymlin this same working directory (the main checkout, not a dedicated worktree).git addwas scoped to the four exact target files throughout, never-Aor., and staging was verified againstgit diff --cached --name-onlybefore every commit.
What else is related? โ
#945: A text-scanning guard cannot tell content from a pattern that matches content: same root cause (a guard that scans raw command text) in the opposite failure direction. #945 is a false positive (the guard blocks agreppattern that searches FOR the character); this project's finding is a false negative (the guard misses a character that never appears literally in the command). #945's own caution against changing the guard's behavior without the operator's word is why this project only fixed the documentation, notguard-em-dash.shitself.docs/projects/pm-lane-guard/: the sibling guard whose door (CLAUDE_ALLOW_PM_BUILD=1) this project used, and whose own header comment already names#945as a design consideration.tooling/scripts/__tests__/guard-em-dash.test.js: the committed test suite that independently corroborates thegit/ghsubstring gate.