Jiufeng
Stop Asking AI to Behave: Prompt Rules to Machine Gates

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.

Vibe Coding

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 in xxx/xxx.go, the default quota in xxx/xxx.go and the registration chain in xxx/xxx.go side 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:

ConstraintWhy it must be a machine
No destructive Gitthere's no undo after reset --hard
commit / push need authorizationonce pushed, the history is public
Credentials and sensitive paths are off-limitsleaks are irreversible
Changes must stay inside the allowlistout-of-scope changes get no review, no record
Guard must pass before reviewotherwise you may be reviewing something else
Review verdicts must be machine-formatfree text gets misread as "approved"
An approved state must carry tests and evidencean 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:

TierFor
denyirreversible and this layer can't judge legitimacy → block
askhigh impact but possibly legitimate → stop and ask
allowfrequent, 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 kubectl pointed 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:

VerdictExit
APPROVE_PHASE0
REQUEST_CHANGES20
ESCALATE_USER30
STOP40

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.

InterceptionCount
Production-cluster marker77
Outside the workspace scope76
git commit24
Governance-file edit18
Deploy-path edit3
git push2
Destructive git restore1
Destructive git reset1

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:

DecisionWhy 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 shipthe 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.