Six guardrails that stop my AI agent mid-command
Six PreToolUse hooks that refuse an agent's command before it runs. What each one caught, and why a guard the agent can talk its way past is not a guard.
Two of these fired on me while I was writing this post.
Not in testing. In the ordinary work of the session — once when I reached for perl to edit a pull
request body, once when a documentation file I was extending would have crossed a line limit. Both
times the command was refused before it ran, and both times the refusal was right.
The case this post is about
One consent latch, checked three times across one session:
$ check_latch training-fold
no consent latch for training-fold
exit 2
# operator confirms in the framework; the framework calls grant_latch
$ check_latch training-fold
exit 0 # audit: latch-used scope=training-fold remaining=1793s
The operation behind it edits a training corpus, which is the kind of thing that must not happen by accident and must be possible when it is deliberate.
An environment variable would do it, and it would last as long as the shell does — which in practice is the rest of the day, so the one deliberate exception quietly becomes a standing permission nobody revoked.
This post follows that latch, because it is one of six guards and the only one whose exception closes itself.
The symptom
An agent with shell access will do the reasonable thing, and the reasonable thing is occasionally catastrophic. Not through malice and not through hallucination — through a locally sensible choice whose consequences live outside the local context.
git checkout -b to tidy up a branch, which discards untracked work. pkill GradleDaemon because
a build looks stuck, which costs half an hour. cut -d= -f1 to list the key names in a properties
file, which prints a secret when the file turns out to be malformed.
Every one of those is a fine instinct. The damage is in what the instinct could not see.
Why the obvious fix failed
The obvious fix is to write it down. Put the rule in the system prompt, in the contributing guide, in the project instructions: never kill the build daemon, never hand-roll a git branch, never run a shell tool against the source of truth.
I did all of that first. The rules were clear, specific, and repeatedly violated — including by me, reading them, in the same session I had written them.
The reason is structural rather than a failure of attention. Guidance competes for attention with the task; enforcement does not. A rule in a document is one consideration among many at the moment of action, and the moment of action is exactly when the task is most absorbing. A hook that returns a non-zero exit code is not a consideration. It is the end of that path.
The second thing I got wrong was allowing the agent to grant its own exception. An early guard honoured an environment variable that the agent could set inline. That is not a guard with an escape hatch, it is a guard with a doorbell.
The actual mechanism
A PreToolUse hook runs before the tool executes, and a non-zero exit refuses the call. The
agent sees the refusal text, not the result, because there is no result.

The six, and what each one cost before it existed:
1. No shell tools on the source of truth. jq, sed, awk, perl are refused against the
idea-layer, which is the specification every generator reads. Scripted edits there produce shallow,
templated, context-blind output, and the layer is supposed to be the considered part. Read-only
observation stays allowed — grep, cat, head, wc, git diff — because looking is not the
problem.
2. No runtime file over 200 lines. Command instruction files are always loaded when the command runs, so length is a permanent tax on attention. The refusal names the remedies and forbids the cheap one: relocate content, never delete a step.
3. Default-deny on secret paths. Not a list of banned commands — an allowlist of permitted ones. This is the one whose first version was wrong in an interesting way, and it gets its own section below.
4. No killing the build daemon. pkill GradleDaemon, ./gradlew --stop, killall java are
refused. A warm daemon with its configuration cache is what makes an incremental build take seconds;
killing it buys a 15–30 minute cold reconfigure. ./gradlew --status stays allowed, and so does
force-stopping the app on a device, which is a different thing that looks similar.
5. No hand-rolled git mutations. git commit, git push, checkout -b, merge, reset --hard are refused in favour of the session scripts that own that workflow. This one is written in
blood: an improvised branch switch once discarded roughly three hours of untracked work.
6. No hand-editing the training corpus. The generator's instruction corpus is written only through its retrain path, because a hand edit is provenance-untracked and the next re-sync silently drops it.
The fix
The design property that makes these work is not the hook mechanism. It is who can lift them.
Every bypass here requires a human to export an environment variable in their own shell, where the export is a real act by a real person. The agent cannot set it inline — an inline forgery is detected and refused — and one guard replaced the variable entirely with a short-lived consent latch: the framework asks the operator to confirm, then grants a 30-minute window that expires by itself and is written to an audit log.
That is the whole difference between a guard and a suggestion. If the thing being guarded can grant itself the exception, there is no guard — there is a speed bump that documents its own removal.
The latch is the version of that with a time limit, and the reason it is better than an environment variable is that it expires whether or not anybody remembers it:
LATCH_DIR="$HOME/.cache/retrain-consent" # <!-- preflight-ignore -->
TTL_SECONDS=1800
grant_latch() { # run only from the framework, after the operator confirms
local scope="$1"
mkdir -p "$LATCH_DIR"
printf 'scope=%s\nexpires=%s\n' "$scope" "$(( $(now_epoch) + TTL_SECONDS ))" \
> "$LATCH_DIR/$scope.latch"
audit "latch-granted scope=$scope ttl=${TTL_SECONDS}s"
}
check_latch() { # the hook calls this; it never grants
local scope="$1" file="$LATCH_DIR/$scope.latch"
[ -f "$file" ] || { echo "no consent latch for $scope" >&2; return 2; }
local expires; expires=$(grep '^expires=' "$file" | field 2)
if [ "$(now_epoch)" -ge "$expires" ]; then
rm -f "$file" # self-expiring: a forgotten latch closes itself
audit "latch-expired scope=$scope"
echo "consent latch for $scope expired $(( $(now_epoch) - expires ))s ago" >&2
return 2
fi
audit "latch-used scope=$scope remaining=$(( expires - $(now_epoch) ))s"
return 0
}
The same scope, three times across one session:
$ check_latch training-fold
no consent latch for training-fold
exit 2
# operator confirms in the framework; the framework calls grant_latch
$ check_latch training-fold
exit 0 # audit: latch-used scope=training-fold remaining=1793s
# 31 minutes later
$ check_latch training-fold
consent latch for training-fold expired 69s ago
exit 2
The third case is the property an environment variable does not have. An export lasts as long as the shell does, which in practice is the rest of the day — so the one deliberate exception quietly becomes a standing permission nobody revoked. The latch closes itself, and the audit line records both that it was granted and that it was used, which are two facts worth being able to separate when you are working out how something got written.
The corollary is that a guard must be cheap to satisfy legitimately, or it gets bypassed for real reasons. Each refusal above names the sanctioned path in its own text. A hook that only says no teaches the reader to route around it.
The one whose first version was wrong
The secrets guard is worth the detail, because its first version looked complete and was not.
The rule was stated the way rules usually are: never cat, head, or tail a secret file. Three
verbs, all the obvious ones. Then a command ran to list the key names in a properties file:
cut -d= -f1 upload_keystore.properties
The file was malformed — one raw value, no = — so cut returned the entire line, and a signing
secret landed in the transcript. Nothing blocked it, because cut is not cat, head, or tail.

The list of ways bytes can leave a file is not a list anyone can finish: cut, awk, sed, grep,
od, xxd, strings, rev, tr, paste, base64, openssl, jq, dd, tee, plus any
interpreter you can name. A denylist of read verbs is not an enforceable shape.
So it was rebuilt as an allowlist: on a secret path, a command is refused unless it is one of a
small set of metadata-only operations — wc -c, stat, test -f, ls, shasum — or one of the
sanctioned secret scripts. The insight that makes it practical is that metadata always suffices to
verify materialization. If a check seems to need the value, the check is wrong.
How we proved it
Two of the six fired during this session, which is the only evidence I actually trust.
The shell-tool guard, on a perl invocation against a file whose content mentioned the protected
path:
RULE-CI-001 — SHELL TOOL ON IDEA-LAYER REFUSED
Blocked: jq/yq/sed/awk/perl on idea-layer
And the line-limit guard, on an edit that would have taken a documentation file three lines past its cap:
BLOCKED — the file would be ~203 lines (limit 200)

Both refusals improved the result. The perl edit became a head/tail/heredoc splice that did the
same job. The over-long file lost a paragraph of rationale that was restating something the same
document already said, and kept the rule.
The honest counter-evidence, and it is real: the shell-tool guard matched on the command string
containing the protected path, not on the file actually being written. My perl was targeting a
temporary file in /tmp; it only looked like a violation because the pull request body I was editing
happened to mention the protected directory by name. The guard was wrong on the facts and right on
the outcome — the splice I wrote instead was better — but a guard that fires on a false positive
spends trust, and enough false positives turn "work around the guard" into a habit. I have not fixed
that one yet, and the fix is to match on the resolved write target rather than the text of the
command.
What this generalizes to
If you are giving an agent real capability, the rules you write for it are the weakest control you have. They compete with the task, and they lose at exactly the moment they matter.
Find the handful of actions that are irreversible or expensive, and make them refuse rather than advise. Then check who holds the key: if the agent can grant its own exception, you have documented a preference. Make the bypass require a person acting in their own shell, make each refusal name the sanctioned path, and expect to pay for it in occasional false positives — which is a much better bill than the one that arrives when three hours of work disappears into a branch switch.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".