
Stop Asking AI to Behave: Prompt Rules to Machine Gates
From Permissions and PreToolUse hooks to the Diff Guard and approval evidence — turning the critical constraints of AI coding from written promises into enforced facts. With 202 real interceptions over three days, and the moment I broke a rule I had written myself that morning.
Part four. The first three covered why you need a Loop, building one with Claude Code + Codex, and stopping drift with Task Packets. This one answers the next inevitable question: the goal is written down — how do you make sure the AI actually stays inside it?
Your prompt probably says something like this:
Don't commit. Don't touch production. Don't go outside the allowed scope.
None of that means those things won't happen.
The conclusion up front:
Critical constraints cannot live only in the prompt the model reads. They have to live in the execution environment the model can't route around.
1. Something I did myself
While writing this series I set myself a rule: no real file paths, project names or internal identifiers in the posts. I wrote it into the process doc. I wrote it into my own checklist.
Then, in part two, I wrote this (redacted here — reason below):
How did Codex find it? It put the implementation in
xxx/xxx.go, the success path inxxx/xxx.go, the default quota inxxx/xxx.goand the registration chain inxxx/xxx.goside by side…
Four real file paths, in an article headed for the public internet.
I hadn't forgotten the rule — I'd written it that same morning. It's just that while writing that paragraph, all my attention was on explaining the idea clearly, and "no real paths" was, right then, item 101 in my head.
What caught it wasn't me. It was a twenty-line scanning script:
❌ matched sensitive-path pattern (1)
The point isn't that I'm careless. The point is:
Between knowing a rule and following it lies not willingness, but mechanism.
I knew the rule. I agreed with the rule. I wrote the rule. All three together still didn't stop me.
A twenty-line script did.
And there's a second half to this story.
Writing this very section, I quoted those four paths verbatim to make the example concrete — the four you see redacted above. My reasoning at the time: they're generic — thousands of Go projects have paths like that. Not a leak.
Then I ran the scanner:
❌ matched sensitive-path pattern (4)
All four.
In an article arguing that machine gates beat human judgment, I broke the same rule again — and this time deliberately, because I believed my judgment was better than the rule.
I could have carved out an exception: "these are harmless, let's allow them." But that is the standard path by which gates die — the first exception is always reasonable, the second cites the first, and by the third nobody remembers why.
So I redacted them. The gate said no, so it's no — even though I think it was wrong this time. If I want it changed, I change the rule itself (and write down why), not route around it this once.
If this can happen to someone with motive, memory, an understanding of the consequences, and who wrote the rule that same morning — then expecting a line in a prompt saying "please comply" to hold a model was never sound to begin with.
2. Why prompt constraints fail
That was the setup. Technically, there are four reasons:
Instruction weight decays over long context. That "don't commit" at the top is, by turn 40, competing with a hundred other things. It hasn't been forgotten. It's just no longer the most important sentence in the room.
The model can misjudge the current authorization. "The user said last round I could touch this file" — that was last round. "The user said this is urgent" — that isn't permission to skip verification. Each inference is reasonable; stacked, they're out of bounds.
Compound commands and indirect scripts route around natural language.
You say "no git reset --hard." Now git reset --hard sits inside a .sh and the model runs the .sh. Violation? How does it decide?
Worse: cd x && make clean && git checkout -- . — natural-language constraints are written about intent, while commands execute by effect.
"I won't do that" is not a safety proof. This is the key one. When the model says "I won't do X," that's a prediction, not a guarantee. And it's predicting its own behavior — a prediction produced by the same system that would produce the behavior.
You cannot use a system's self-report to prove that system's safety.
3. Which constraints deserve a machine
Not everything is worth mechanizing — mechanizing costs something. The test is simple:
Irreversible, blast radius beyond this round, or "if it goes wrong you can't trace it" — those must be mechanized.
By that test, I mechanized seven:
| Constraint | Why it must be a machine |
|---|---|
| No destructive Git | there's no undo after reset --hard |
| commit / push need authorization | once pushed, the history is public |
| Credentials and sensitive paths are off-limits | leaks are irreversible |
| Changes must stay inside the allowlist | out-of-scope changes get no review, no record |
| Guard must pass before review | otherwise you may be reviewing something else |
| Review verdicts must be machine-format | free text gets misread as "approved" |
| An approved state must carry tests and evidence | an approval without evidence isn't an approval |
Conversely: reading code, editing a function, running a targeted test — none of that should prompt. It's reversible, small, and fails loudly.
4. Four layers
My implementation has four, each judging something different:
① Harness Permissions ← hard limits before the tool call
↓
② PreToolUse Hook ← understands the current task context
↓
③ Diff Guard ← verifies what actually happened
↓
④ Evidence Freeze ← an approval must carry evidence
The layers aren't redundant. They judge at different moments, with different information.
① Permissions: hard limits before the call
Outermost, coarsest. It has no context; it matches command patterns:
"deny": [
"Bash(git reset --hard*)",
"Bash(git clean*)",
"Bash(git checkout --*)",
"Bash(git restore*)"
]
All four irreversibly discard working-tree changes. They aren't "risky" — they're unrecoverable. So: deny, not ask — because at this layer I don't believe there's a "confirm and it's fine" case.
Three tiers, kept distinct:
| Tier | For |
|---|---|
deny | irreversible and this layer can't judge legitimacy → block |
ask | high impact but possibly legitimate → stop and ask |
allow | frequent, low-risk, reversible → don't prompt; prompting here is harassment |
② Hook: understands the task
Permissions only sees a command string. The Hook can see what the current task is.
It answers what Permissions can't:
- Are the packet, the checkout and the guard profile bound together?
- Is this path in a sensitive zone (deploy / production / SQL)?
- Is the review command missing its Guard?
- Is this
kubectlpointed at the self-hosted cluster or production?
That last one is the interesting one. It matches on markers:
has_prod_marker() {
[[ "$cmd" == *"k8s/production"* ]] && return 0
[[ "$cmd" =~ --context[=\ ][^[:space:]]*(prod|prd|production) ]] && return 0
...
}
Match → ask. No match, self-hosted cluster → fast path, no prompt.
That's the Hook's whole reason to exist: the same kubectl rollout restart is routine against your own cluster and an incident against production. Permissions can't tell them apart. The Hook can.
③ Diff Guard: what actually happened
The first two layers check tool calls. The Guard checks the real working tree.
That difference matters, because a call isn't the outcome:
- the model may edit a file and edit it back (the call happened, the result didn't change);
- the model may change a file indirectly via a script (the Hook saw "run script," not "edit file");
- the model may have committed and then kept going.
Why git status isn't enough: after a commit, those changes vanish from git status. You see a clean tree and conclude "not much changed" — while this round's changes are all hidden inside the commit.
So the Guard must compare against the HEAD baseline: what was HEAD when this round started, and what's the real diff now (including what's committed). Pin the baseline and changes can't hide.
allowlist and denylist also do different jobs:
- allowlist: the paths in this round's packet. Different every round.
- denylist: credentials, production config, secrets. Always on, regardless of round.
④ Evidence Freeze
The last layer doesn't block operations. It blocks the act of approving.
5. Review verdicts must be machine-readable
Looks fussy. It's actually load-bearing.
The reviewer's verdict must be one of four, expressed with a unique final-line marker and an exit code:
| Verdict | Exit |
|---|---|
APPROVE_PHASE | 0 |
REQUEST_CHANGES | 20 |
ESCALATE_USER | 30 |
STOP | 40 |
Why not free text: because review reports contain sentences like this —
"Apart from the three MAJOR findings above, the rest can be approved."
A keyword-based parser sees "can be approved" and lets it through. What the sentence actually means is not approved.
Free text is for humans. A machine decision needs something a machine can parse unambiguously.
One more thing people miss: other exit codes do not mean "the code was rejected." Review timeouts, network failures, bad identity config — none of those are REQUEST_CHANGES. They're infrastructure failures. Treat them as code problems and you'll start fixing a bug that doesn't exist.
6. Why approval evidence is fail-closed too
Fail-closed means: when the mechanism itself breaks, deny by default rather than allow by default.
So these four are hard:
- No tests, no approval — an approval without a test run approves what, exactly?
- Guard failed, no approval — a broken Guard doesn't mean "no violation." It means you don't know.
- Unparseable review, no approval — you don't know the verdict; that isn't a pass by default.
- Failed patch freeze doesn't quietly continue — evidence that isn't frozen can't be reproduced in three months.
There's an implementation detail here I like. My Hook needs jq to parse tool input. If jq isn't there:
"jq is missing, cannot verify tool input. Please confirm manually."
→ permissionDecision: "ask"
When the gate itself is broken, it doesn't let you through. It escalates to a human.
That's the essence of fail-closed: "I couldn't check" and "I checked and it's fine" must never produce the same result.
7. Real numbers: 202 in three days
None of this is decorative. Pulling the records: 202 triggers in three days, ~67 a day.
| Interception | Count |
|---|---|
| Production-cluster marker | 77 |
| Outside the workspace scope | 76 |
| git commit | 24 |
| Governance-file edit | 18 |
| Deploy-path edit | 3 |
| git push | 2 |
| Destructive git restore | 1 |
| Destructive git reset | 1 |
Those last two are the most valuable rows in the table.
One git reset. One git restore. Both irreversibly discard the working tree. Both were hard-denied.
The sentence "please don't reset" would not have stopped either one.
And the top two rows (77 production markers, 76 scope exits) say something else: touching these boundaries is routine, not exceptional. Sixty-plus times a day — if a human had to watch for that, you'd never get anything else done.
8. Not everything should be mechanized
This section matters as much as the last.
What must stay with a human:
| Decision | Why a machine can't |
|---|---|
| Architectural semantics | "single-row rotation or a plan row per generation" — a tradeoff, not a right answer |
| Product behavior | "should rate limiting count IPs or failures" — that's product strategy |
| Risk acceptance | "this concurrency window is tiny — acceptable?" — only you know what the business tolerates |
| Whether to ship | the machine doesn't know today is a launch day |
| Trading correctness for availability | "better to retry twice than drop an order" — a commercial call |
In the rate-limiting case from the last post, after the reviewer found the implementation didn't match the requirement semantics, it didn't fix it. It wrote: whether to keep a pure per-IP quota or move to failure-counting with reset-on-success is a security product decision requiring the user.
That's correct. A gate's job is to stop the problem at the door, not to decide for you.
9. Avoiding gates that are too strict
Too loose is useless. Too strict drives people insane — and the outcome is the same: you start clicking "confirm" without reading.
At that point the gate is fully defeated. It's become a decorative dialog.
Four rules:
1. Reads, edits and targeted tests don't prompt. Reversible, small, loud on failure. Prompting for these just trains the user to ignore prompts.
2. Routine operations on your own environment take a fast path.
The same kubectl against your own cluster and against production are different acts. The Hook must tell them apart.
3. Only high-impact operations ask for authorization. The test is "irreversible" or "blast radius beyond this round" — not "looks kind of scary."
4. Failures in collection and retrospection must never block delivery. The easily-missed one: the recording mechanism must not become a gate. If the retro inbox can't write, it doesn't write. It doesn't stop the work.
I got this one wrong. My retro inbox recorded all 202 of those gate triggers — but those 202 aren't problems. They're the gates working as designed. 96% of the inbox was "system nominal," with the real signal buried inside it. Recording "the system is fine" manufactures noise, and noise teaches people to ignore the inbox. That one gets its own post.
Conclusion
Four sentences, four jobs:
The prompt expresses intent. Hooks and Guards enforce boundaries. Tests verify behavior. Humans make the calls that can't be automated.
Confuse any layer and something breaks:
- treat the prompt as a boundary → it fails at turn 40;
- treat the Guard as a test → it knows where you changed things, not whether they're right;
- treat tests as review → they only cover the path you thought of;
- treat the machine as the decider → it will pick a product strategy it has no standing to pick.
Back to my own story: I knew the rule, agreed with the rule, and wrote the rule — and still broke it.
If all three of those together can't hold one person, then "write please comply in the prompt" was never a boundary.
It was a wish.
Next: independent review — 41 real findings sorted into 7 failure modes, and why "let the AI self-review in a fresh session" never works.