DEV Community

Cover image for The guard that would have blocked its own investigation
Willian Pinho
Willian Pinho

Posted on • Originally published at willianpinho.com

The guard that would have blocked its own investigation

Deny-by-default is the standard posture for a safety guard: start closed, allow explicitly, add exceptions when someone complains. I argued for exactly that three weeks ago, about agent tool access: fail-close is a default you choose before the first tool is registered, not a guardrail you bolt on after, and the opposite instinct is backwards, and it's going to cost teams who don't fix it before they go to production.

Then I wrote a different guard and deliberately made it fail open. Its header says so in one line: A false block is worse than a miss here.

Both are correct, and the thing that decides which one you want is not a philosophy. It took a hook blocking cat on the script it was guarding to make me write down what it actually is.

What the guard was for

On 2026-07-29 an agent ran the publish ceremony from the main checkout of a repo while that checkout sat on the default branch. The publish script mutates tracked files as a side effect, a metrics CSV and an index, so running it is a write to the repo rather than a read. That repo requires a feature branch and a PR for writes.

There was already a branch guard in that repo. It did nothing here, because it only guards git checkout|switch, not repo-mutating scripts. The rule was "don't write to the main checkout." The guard implemented "don't change branches there." Those are not the same rule, and the gap between them is exactly wide enough for a script to walk through.

So: a new hook. Block the publish engine when it runs from the main checkout on the default branch. Simple enough to write in an afternoon.

The first version blocked the investigation that produced it

The first implementation matched the script name anywhere in the command line. If publish.mjs appeared, exit 2.

That is the obvious way to write it, and it is wrong in a way that is invisible until you use it. Four commands started returning exit 2:

  • cat content/scripts/publish.mjs
  • grep -n frontmatter content/scripts/publish.mjs
  • git diff content/scripts/publish.mjs
  • node --check content/scripts/publish.mjs

None of those run anything. They read a file. But the filename was in the command line, so the guard fired. The commit says it plainly: it would have blocked the very investigation that produced this hook.

To understand the incident I had to read the script that caused it, and the guard I was writing to prevent the incident had just made that script unreadable. A miss would have cost me two dirty tracked files, a metrics CSV and an index, both recoverable with git restore. The block cost me the ability to open the one file the investigation was about.

Positional, not textual

The fix was to stop asking "does this string appear" and start asking "where does it appear." A command line gets split into top-level segments on ;, &, and |, quote-aware so a separator inside a quoted string does not split it, and each segment is tokenized into argv. Then two shapes count, and only two:

if (GUARDED_SCRIPTS.includes(firstBase)) return true;
Enter fullscreen mode Exit fullscreen mode

The script is the command being run. Or it is the entry-point argument to node, with parse-only flags excluded, because node --check x.mjs parses and exits. Leading NAME=value assignments get skipped so FOO=bar node publish.mjs still resolves to the right first token.

Everything else passes. cat, grep, git diff, wc, bat, echo mentioning the path in prose: the filename is an argument to a reader, never a thing being executed, so it never counts.

The rewrite is about 87 lines of tokenizer. The insight is that a guard operating on text has no idea what the text means, and command lines have a grammar where position carries the entire meaning.

The asymmetry that picks the default

Asymmetric cost is not a new idea. Anyone who has priced a risk has met it. What is easy to miss is that it applies to the guard you are writing right now, and that the answer it gives can be the opposite of the one you gave last month.

Deny-by-default is not a universal virtue. It is the correct answer to a specific question: which direction of error can I not recover from? For an agent holding a database connection, the unrecoverable direction is the miss. A DELETE that should have been denied does not un-run. Blocking a legitimate call costs a round trip and an explicit grant, and you get to try again. Miss is fatal, block is cheap, so deny by default.

Invert the costs and the answer inverts with them. For a workflow guard, a miss leaves a modified file in a checkout that git will happily show me and revert. A false block removes my ability to inspect the system, and it does it while printing a confident message describing a violation that never happened. Nothing in that message tells you the guard is the thing that is wrong. Miss is cheap, block is expensive, so pass by default. Every ambiguous detection step in that hook exits 0: git not available, not a repo, detached HEAD, all pass.

Same principle, opposite configuration. What travels between them is not the default. It is the question.

The failure mode I want to name is picking the default by vibe. Security-adjacent work has a gravitational pull toward strictness, and strict feels responsible, so guards get written closed without anyone pricing the block. The first version of my hook was strict. It was also useless, and it took blocking git diff on the script it guards to show me.

Document the holes instead of papering over them

The finished guard does not catch everything. An execution wrapped in bash -c, hidden in a for loop, or launched through npx tsx walks straight through. Those are written into the file header as confirmed pass-throughs, with a do NOT try to close these instruction attached, because closing them would mean parsing nested shell grammar, which buys the false blocks back.

That is a real trade and it is stated where the next reader hits it. The header fixes the scope in the same place:

This guard is a guardrail against the ACCIDENTAL case (an agent typing the obvious command in the main checkout), not a security boundary against a determined caller who deliberately obfuscates the invocation.

A guardrail that admits its own holes is honest. A guardrail that implies it has none is worse than no guardrail, because people stop checking.

The four commands that broke are now the test suite's job, pinned as named allow cases: one asserts that node --check only parses and does not run, another that git diff on the guarded script passes untouched. 17 cases in all, and the commit records 25 more exit-code shapes run by hand, covering every block shape, dry runs, linked worktrees, outside-repo, and the bypass.

The question worth stealing

Before you set a default, price both errors in the units that actually matter, and pay attention to whether the expensive one is the block. My miss was cheap because the blast radius was two files in a checkout I control. If yours writes to a production table, the arithmetic runs the other way and you should close the guard. The point is to do the arithmetic rather than inherit the answer.

If you cannot say what a false block costs, you have not designed the guard. You have expressed an attitude about risk and let it compile.

In my case, the tell was a guard whose own maintenance path ran straight through the thing it blocked. You're always the first person to hit whatever your own guard breaks, and nobody writes a test for that first case until it has already happened to them.

Top comments (0)