AI Agent Permissions: Gate Irreversible Actions, Not Diffs

A reversibility split cuts one migration session from 12 interrupts to 3, and still catches the one call that matters.

AI Agent Permissions: Gate Irreversible Actions, Not Diffs

You set up permissions so the agent would ask before doing anything risky. Weeks in, you’re clearing a stream of yes/no prompts without reading most of them, because most of them are ls and git diff and a dry run against a replica that isn’t serving anyone. Then, on an ordinary Tuesday, the migration applies to production two calls after a prompt you already waved through, and you realize what you’d actually been approving: noise, with one real prompt buried somewhere in it, and no way to tell which one it was.

That’s the actual failure, and it isn’t that the agent acted without review. It’s that review keeps landing on the wrong things, because most permission setups gate on how a command looks instead of on what it does. A command that looks scary and a command you can’t undo are not the same list, and the gap between them is exactly where a migration hits prod on a Friday.

Before any of the numbers below, run a thirty-second check on your own setup.

Recall your last real session, or open the permission log if you keep one. Count how many times you got asked to approve something. Then count how many of those approvals guarded an action you couldn’t undo - gone for good, where undo means a dropped table stays dropped, a sent email stays sent, a charged card stays charged.

If most of your prompts fired on things you could undo in five minutes and one or zero fired on something you couldn’t, you have this problem, and the rest of this post is worth reading. If every prompt already guards something irreversible, your setup draws the line correctly. Close the tab.

The ratio between those two counts is the entire problem, and you just measured it yourself.

Pick one concrete session and keep it for the rest of this post: a migration that renames and backfills users.full_name, then applies it to production. Twelve shell calls, in order, is a normal pass through that work.

  1. ls migrations/
  2. grep -r full_name .
  3. grep -r users db/
  4. grep -r backfill scripts/
  5. git status
  6. git diff
  7. npm test (before writing the new migration)
  8. psql -f migrate.sql --host=staging (apply to staging, the dry run)
  9. npm test (after writing the new migration)
  10. git commit -m 'add users.full_name backfill'
  11. git push origin feature/backfill-full-name
  12. psql -f migrate.sql --host=prod (apply to prod)

One note before the arithmetic: the log is a toy, invented for this post and sized so you can check every count by hand. It is not a transcript.

Now apply the setup a lot of teams actually ship: ask before every shell command. Every one of those twelve calls fires a prompt. You skim twelve yes/no questions to get one migration out the door.

Of the twelve, exactly one call can hurt you if you approve it wrong: call 12, the apply against prod. Everything before it is a read, a test, or a dry run against a replica that isn’t serving traffic. Calls 10 and 11 write to a branch and a remote you don’t deploy from - a bad commit is a git reset, a bad push is a force-push over the same branch.

So the naive policy fires 12 prompts to guard 1 action that matters. Call that fraction the signal ratio: how many of your interrupts actually guard something you can’t undo, out of all the interrupts you get. Here it’s 1 in 12. 8.3% signal. The other 91.7% of your attention goes to calls that were never going to hurt you.

This is the same shape as security alert fatigue, or a CI suite where four flaky tests fail on every run alongside the one real regression. The mechanism is the ratio: when only one prompt in twelve guards anything real, attention gives out, you learn the rhythm of twelve ys, and your thumb moves faster than your eyes. The prompt on call 12 then gets the same half-second as the prompt on call 1, and review arrives, technically, as noise.

Here’s where the fix ends up, so you can watch the piece earn it: the same twelve-call session drops to 3 interrupts instead of 12, and the one call that matters is still one of them. 33.3% signal, a 4x cut in interrupts, and nothing you needed to catch slips through.

Twelve prompts asked “is this scary.” Only one of them needed to ask “can this be undone.” That’s the entire fix, reframed as a single question you can run against any tool call before it fires:

If it runs and it’s wrong, can you undo it?

Call this the undo test. It has nothing to do with how a command looks. git reset --hard looks like destruction and is completely reversible - the reflog still has the commit you reset away from. git push looks routine and is exactly as reversible as that reset - you can force-push over it from your local branch. Neither needs a human in the loop. The apply against prod looks like the least dramatic line in the whole session, one psql call, no flags that scream danger, and it’s the only one of the twelve where the answer to the undo test is no.

Run the undo test against all twelve calls and you get three buckets, not two. Reversible-and-cheap calls need no gate at all. Reversible-but-consequential calls, a commit, a push, are worth a pause, because undoing them costs you something even if it’s not permanent. Irreversible calls get a hard stop, always, no exceptions.

Scariness is a guess about a command’s appearance. The undo test is a fact about a command’s consequence, and it’s the only one of the two you can encode into a rule.

Step 1 - Split the toolset by reversibility in permissions

Section titled “Step 1 - Split the toolset by reversibility in permissions”

Permissions is where you encode the undo test so the agent obeys it without you watching. The shape is an allowlist with three buckets, allow / ask / deny, and the trick from the last section is the rule that sorts each tool into one of them.

Run it against the twelve-call session:

CallsWhat they doBucket
1-6ls, three greps, git status, git diffallow
7, 9npm test, before and after the new migrationallow
8psql -f migrate.sql --host=stagingallow
10git commit -m 'add users.full_name backfill'ask
11git push origin feature/backfill-full-nameask
12psql -f migrate.sql --host=proddeny

Nine calls need nobody; they run silently. Two are reversible but consequential enough to pause on. One never runs without a human saying yes. Collapse the allow bucket out of your prompt count, since it fires with no interruption, and the twelve prompts of the naive policy become three.

Check it against the earlier count: same twelve calls, same one irreversible action. The only thing that changed is the sorting rule.

3 prompts instead of 12, and the one that matters is still one of them: 1 in 3, 33.3% signal, the number promised two sections ago.

Read the three buckets as a policy statement. allow is everything reversible - silent, full speed, no interruption. ask is the reversible-but-consequential middle - it pauses for a yes. deny is the irreversible class that never runs from inside the agent loop at all; a human does it by hand, or it runs through a path you gate separately.

Here is the split as a Claude Code settings.json. opencode keys the same three actions, allow / ask / deny, directly off each tool (edit, bash, webfetch) with per-command patterns underneath. Codex draws the line a layer down, through its sandbox mode plus an approval policy (untrusted, on-request, never, or a granular policy that sets the level per category). The mechanisms differ; the reversibility logic you encode into them is identical.

{
"permissions": {
"allow": [
"Read",
"Grep",
"Glob",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(npm test:*)",
"Bash(psql -f migrate.sql --host=staging:*)"
],
"ask": [
"Bash(git push:*)",
"Bash(git commit:*)",
"Edit"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(git push --force:*)",
"Bash(terraform apply:*)",
"Bash(psql -f migrate.sql --host=prod:*)"
]
}
}

One thing the matcher won’t do for you: catch a DROP buried inside a psql -c "..." string. Claude Code’s patterns match on the command prefix, not arbitrary substrings, so Bash(psql:*) matches any psql call with no way to fire only when the payload contains DROP TABLE. Don’t fake it with a glob that looks like it works. Mid-string content is the hook’s job, in Step 3.

But deny is blunt. It bans the tool outright, so the agent can’t even propose call 12 as the last step of a plan - fine for rm -rf, too blunt for the one destructive action you actually want to happen, once, after you’ve seen it coming. Permissions get you the count. Seeing the call coming and enforcing it are the next two layers’ jobs.

Step 2 - Force a plan so the action is visible before it runs

Section titled “Step 2 - Force a plan so the action is visible before it runs”

The diff-review problem is a timing problem: you review the consequence after the cause. Plan mode fixes the timing by making the agent produce its full intended action list before it touches anything, then stop.

In plan mode the agent reads, reasons, and emits something like this for the same session, then stops:

PLAN
1. Read migrations/ to find the column rename. (read-only)
2. Write a new migration that backfills users.full_name. (reversible)
3. Run the migration against the staging replica as a dry-run. (reversible)
4. Apply the migration to production. (IRREVERSIBLE - requires approval)
Awaiting approval before step 4.

Line that plan up against the twelve-call session and it’s the same three prompts as Step 1, just visible earlier. Step 4 in the plan is call 12, and now you’re reading it as a line item before it runs instead of catching it mid-scroll after it already has. The count holds at 3; what plan mode buys with that same count is advance sight of the one that matters.

One thing decides whether you keep using this gate or start clicking through it: approving has to be cheap. If saying yes restarts the session and the agent re-derives the whole plan, re-running searches it already did, the gate taxes you every time and you’ll stop reading it. The property that matters: approval resumes from the plan, everything the agent already learned still in place, so the only thing that changes is the decision you made.

But a plan is text. In a long enough session, or after a compaction, the model can write “awaiting approval before step 4” and then forget it said that. Plan mode makes the call visible. It doesn’t make the call enforced, and that’s the gap the next layer closes.

Step 3 - Gate the destructive step behind a hook that interrupts

Section titled “Step 3 - Gate the destructive step behind a hook that interrupts”

Hooks are deterministic code that runs at fixed points in the agent loop, including before a tool call executes. A hook doesn’t ask the model nicely. It runs your script, and if the script says no, the tool call dies. That’s the hard stop the plan only promised.

Here’s a PreToolUse hook that intercepts call 12 specifically, and anything shaped like it. The hook receives the tool call as JSON on stdin, not through an environment variable, so pipe stdin into jq to pull the command out:

.claude/hooks/gate-irreversible.sh
#!/usr/bin/env bash
# Fires before every Bash tool call. Reads the tool call as JSON on stdin.
# Exit 2 = block the call and return the message to the agent.
cmd=$(jq -r '.tool_input.command') # JSON arrives on stdin
irreversible='(terraform apply|DROP TABLE|DELETE FROM[^;]*;|git push --force|send_email|charge_card|payments_ledger)'
if [[ "$cmd" =~ $irreversible ]]; then
echo "BLOCKED: irreversible action detected:" >&2
echo " $cmd" >&2
echo "This class of command is gated. A human must run it by hand," >&2
echo "or approve it through the harness before it can proceed." >&2
exit 2 # non-zero (2) blocks the call; stderr is returned into the agent's context
fi
exit 0

The contract is the one the hooks chapter documents: exit 2, with your message on stderr. The non-zero exit kills the call before it runs; stderr goes back into the agent’s context, so the model sees why it was stopped and re-plans around it. The script doesn’t approve anything, on purpose - approval stays in the harness, a human deciding in that moment. Want it to prompt instead of hard-block? Exit 0 and print a hookSpecificOutput block with permissionDecision: "ask", and the call routes into the approval prompt instead of dying.

One thing to know about where this runs: a PreToolUse hook fires inside the agent’s process, which often has no controlling terminal. An interactive prompt (read -r answer < /dev/tty) works at your desk and hangs the moment the same agent runs headless in CI. The portable gate is exit 2 plus stderr, identical whether a human is watching or not.

Wire it into the loop:

{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/gate-irreversible.sh" }
]
}
]
}
}

This is deterministic: it fires every time the pattern matches, whatever the model believes about its own plan. opencode reaches the same place with a plugin that runs before each tool executes; wherever your harness puts the gate, the property to insist on is code the model cannot argue with.

The one way this turns on you is over-gating. Write the pattern too wide, a bare DELETE FROM with no qualifier, and you block a reversible DELETE FROM scratch_tmp inside a transaction you could roll back. The moment the gate fires on routine work, you start waving it through: the rubber-stamp reflex in a new hat. Unsure whether something belongs in the regex? Run the undo test on it.

The hook is the only one of the three layers that survives the model forgetting its own plan, because it never asked the model to remember anything.

Five rows, and only two of them still change

Section titled “Five rows, and only two of them still change”

Stack all three layers and replay the same twelve-call session through each:

LayerPrompts this sessionSignal ratioCaught before it runs?Survives the model forgetting?
Nothing (full autonomy)0n/aNoNo
Ask on every Bash call128.3%NoNo
+ Permissions (allow/ask/deny)333.3%NoNo
+ Plan mode333.3%YesNo
+ Hook (deterministic gate)333.3%YesYes

Read the first two columns down and then stop. Permissions do the entire 4x cut by themselves, 12 down to 3, and nothing after that row touches the count or the ratio again. That’s not an error. Once you’ve split the tools correctly, 3 prompts for a 12-call session is the floor: exactly one action in this session nobody but a human can approve, so at least one prompt has to exist, and the two ask-bucket calls keep their pause.

So plan mode and the hook aren’t there to shrink a number. They earn the last two columns instead: whether you see the irreversible call coming, and whether the gate holds up after the model has forgotten it ever mentioned step 4. A team that stops at permissions has already bought the 4x cut and is still gambling on the model’s memory for the one call that matters.

Row 3 is where the count stops moving. Rows 4 and 5 are where the trust starts.

Four things worth naming, so you don’t assume they’re covered here.

A keyword blocklist is the tempting middle option, and it’s worse than either end. “Ask if the command contains push, prod, or deploy” sounds like the undo test without the bucket work. It over-fires on git push origin feature/backfill-full-name, a reversible push to a branch you don’t deploy from, and it under-fires the moment someone runs a bare terraform apply that your keyword list never named. It’s still gating on how a command looks - just looking at the string instead of your gut.

Claude Code’s Auto Mode, and any AI classifier playing the same role, has the same ceiling as the blocklist, just a smarter one. Auto Mode, as Anthropic’s 2026 documentation describes it, reads your permission rules in prose and decides allow-versus-ask per call from context, rather than by static pattern. That’s real progress over a blocklist. But a classifier still can’t know that payments_ledger is append-only by internal policy, or that your staging alias points at prod during the Friday migration window. Those facts aren’t in the command text for anything to infer. The allow/ask/deny split is where a human writes those facts down as rules instead of leaving them to be inferred.

MCP tool annotations solve a version of this one layer down. A server that marks a tool destructiveHint and pauses for confirmation puts the same kind of gate inside the tool itself, so it travels to any client that respects the annotation - see Elicitation plus tool annotations. This piece’s gate sits in the harness instead, which is the only place that reaches raw shell calls no MCP server ever got a chance to annotate.

Regulatory human-oversight requirements are a different job entirely. The EU AI Act’s Article 14 and the NIST AI RMF both ask for human oversight of consequential automated decisions, and a regulated org will want an audit trail on top of this, not instead of it. This piece is the mechanical gate. The paperwork a compliance team wants sits above it.

One more axis, named rather than ignored: some guardrail frameworks also escalate on the model’s own confidence, pausing when it reports low certainty or a call looks anomalous against its history. That isn’t competing with the split in this piece. Reversibility asks whether a call can be undone; confidence asks whether the model seems sure. A setup can run both without either doing the other’s job.

None of the five replace the reversibility split. Four are worse approximations or sit a layer over; the fifth answers a different question.

The list you still have to maintain by hand

Section titled “The list you still have to maintain by hand”

Replay the table, because it is the thing you actually built:

LayerPrompts this sessionSignal ratioCaught before it runs?Survives the model forgetting?
Nothing (full autonomy)0n/aNoNo
Ask on every Bash call128.3%NoNo
+ Permissions (allow/ask/deny)333.3%NoNo
+ Plan mode333.3%YesNo
+ Hook (deterministic gate)333.3%YesYes

Row 1, the migration runs whenever the model decides it should. Row 2 buys the illusion of review: twelve prompts, one real, a thumb that learns the rhythm. Row 3 is where the count stops: 3 is the floor, one action that needed a human and two pauses that earned their keep. Rows 4 and 5 buy the only two things left: the one prompt that matters, read before anything runs, and a gate that still holds after the model has forgotten it wrote one. The count has nothing left to cut. What remained to fix, after row 3, was making sure the one interrupt that stays cannot be talked past, by a confident model or by your own habit of clearing prompts on autopilot.

One job is still yours. The regex in Step 3 is a list a human wrote down once, terraform apply, DROP TABLE, payments_ledger, and it stays correct only as long as someone keeps editing it by hand. Rename the ledger table, stand up a new prod-only script, and the hook doesn’t know to watch it until someone updates the pattern. A bigger prompt can’t keep that list honest, and the model is the wrong keeper: it’s the thing you didn’t trust to remember the plan in the first place. The job needs something that diffs the hook’s regex against the tables and commands that actually exist, and flags the gap before the list goes quietly out of date.

Three prompts, one of them the call that matters, and a gate that holds even when nobody’s watching: that’s what the split buys. Keeping the list behind it honest is the part still yours.


About the numbers. The twelve-call session and its allow/ask/deny classification are a toy, invented for traceability and sized so you can check every count by hand, not a transcript of a real run. Every ratio that follows it (8.3%, 33.3%, the 4x reduction, all five rows of the layer table) is arithmetic derived from those twelve calls and re-checked with a script before publishing - none of it is a measurement of your own repo. Swap in your own session and the shape holds: whatever your naive prompt count is, splitting by reversibility drops it to your ask-bucket calls plus your deny-bucket calls, and the one call that matters stays caught either way.

This piece gates when a human sees an agent’s action, by reversibility. Sibling posts that gate something adjacent are worth telling apart. Full autonomy is a small blast radius cuts what the agent can reach at all, not when a human reviews what it does inside that reach. Build risky agent code behind a flag allocates code-review scrutiny by blast radius, for code you read at leisure, not a call that already ran. A single authorization gate for agents is authorization logic inside the software the agent builds, not the harness gate on the agent itself. Quiz me on the diff grades whether an agent’s summary is honest about what changed - a spin problem, not a timing problem, despite sharing the word “diff.” And The relay, not the window and Treat the plan file as external memory use “the plan” for session continuity, a checklist a fresh session reads to resume, not the reviewable artifact this piece pauses on.

For the per-tool mechanics, see Permissions for splitting tools by reversibility, Plan mode for making intent reviewable before action, and Hooks for the deterministic gate the model can’t drift past.