I changed my hooks from ask to deny. The first thing they stopped was my own work
On September 25 I read a report of Claude Code signing a contract in a browser and nearly sending it. I wrote a hook to stop the buttons you can’t take back, the send, buy and sign kind, and tried it in a fresh session in auto mode. I had it click “Submit order” on a test form. The hook fired and returned a value saying “ask the user first”. No prompt appeared. The form was submitted.
The transcript had the hook’s output in it, and the shape was right: "permissionDecision": "ask", with a reason attached. The official docs say that prompts a hook asks for are still shown in auto mode. On my machine they weren’t. Whether that’s a version difference or something specific to the browser tool, I haven’t pinned down. What I learned is only this: whether “ask” turns into a confirmation is not something I can guarantee from my side.
If you want it to stop, the only option is deny. I changed the same click to deny, had it clicked again, and the page was not submitted.
Three days later, my own fences all said “ask” too
I published that hook, but that wasn’t the end of it. Three days later, on the 28th, I was going over my settings for an unrelated reason and noticed. I have three Bash hooks of my own. One watches pushes to master and one watches PR merges; both have been there since July. From Permissions have a range in June to Defenses have a range too in July, I had been moving failures caught by the memory rules (the side of the defense that makes Claude read a record of past mistakes at every startup) over to the machine side. These two hooks are that machine side. The third watches irreversible operations like recursive deletes and force pushes, and I had written it on the 25th.
All three returned ask.
The one from the 25th has a comment at the top that says: this also reacts to strings inside quotes, but it only falls to ask, so that’s acceptable. Going by the backup log, I wrote that line a few minutes before watching the test form go through. Right after writing “ask is harmless”, I saw ask fail to become a confirmation. At the time the only fence in my head was the one I was publishing. I never connected it to the three at home.
I run in auto mode for long stretches. These three hooks may have been letting things through, without a prompt, during exactly the hours I watch least. Since June I’ve had five repeat incidents of a confirmed operation going past the range of my permission: two pushes and three merges. The merge hook has been live since July, and in July I even wrote that I had audited it and closed the gap. Whether that closed fence was a fence at all in auto mode, I can’t say.
How far to take deny
Deny is stronger than ask. Ask goes through if a human allows it; deny doesn’t go through even if a human allows it. If I say “go ahead”, Claude still can’t run the operation, and I end up doing it myself. So I couldn’t make everything deny.
I sorted the three hooks by operation.
| Operation | Decision | Reason |
|---|---|---|
PR merge (gh pr merge) | deny | I can do it myself. Can’t be undone. Three repeats (more below) |
| push to master | stays ask | This blog deploys on push to master. I want Claude to keep doing the publishing |
| Externally destructive operations (force push, deleting a remote branch, deleting a repo or release on GitHub, wiping a disk, SQL DROP and TRUNCATE) | deny | Not used in Claude’s normal work. If it needs doing, I do it |
Local operations (rm -r, git reset --hard, checkout --, restore, clean -f, branch -D, stash drop) | stays ask | Claude uses these legitimately mid-task. What’s lost is local changes, recoverable from git and the automatic backup |
Two lines decide it: can it be undone, and does Claude need to do it. Push can’t be undone, but Claude needs it to keep publishing, so it stays ask. Local deletes are needed mid-task and recoverable, so they stay ask. Merge and external destruction can’t be undone, and I can do them myself without Claude, so they’re deny.
There’s one more reason merge went to deny. In all three repeats, Claude took a permission or request I had given and stretched it to cover a merge. Once it carried “merge these in order”, said about a different batch of PRs, over to an unrelated one. Twice it read my OK to “fix anything you find” or “go ahead up to the tag” as including the merge. If it stays ask, the same stretch can go through again. With deny, my permission simply doesn’t apply to merge, so there’s nothing to stretch.
What stays at ask depends on whether a prompt appears in auto mode. Where it doesn’t, the only thing guarding push is the memory rules.
Two stops in 33 seconds, both my own work
At 0:14 on the 28th I rewrote the merge hook to deny. To check it, I had Claude run gh pr merge against a PR number that doesn’t exist. It stopped. So far, as intended.
At 0:23 I rewrote the irreversible-operations hook: check the externally destructive side first and deny it, keep local operations at ask, and if both appear in one command, deny wins. That rewrite went through.
The unintended stops start here. Right after, a sed that added a note to the comment at the top of the hook was stopped. Reason: “SQL DROP / TRUNCATE”.
I wasn’t running dangerous SQL. The note I was adding said the hook also reacts to a "drop table" inside quotes, and that string was in the sed argument. The hook reads the command string with a regex. A sed writing a comment and a sqlite3 dropping a table are the same text, drop table.
Thirty-three seconds later, it stopped again. This time it was the memory note Claude leaves for its next session. A heredoc trying to record exactly this behavior, “it stops even when the command string merely contains drop table”, contained that sentence, so it stopped. The note explaining why things stop was stopped for that reason.
Both times I fixed my side, not the hook. The note that got stopped went in reworded so it didn’t contain the trigger. The memory note I wrote with the file-writing tool instead of Bash. I recorded in my decision notes that this is how I’ll handle such strings from now on. The hook’s comment ended up changing from the 25th’s “it only falls to ask, so acceptable” to “if it’s a deny target it stops; the user can run it, so acceptable”. The reason for tolerating it changed from “it’s harmless” to “I’ll cover it myself”.
A fence that reads text fails in both directions
In July’s Defenses have a range too, I wrote about gh pr merge walking past a hook that watched git push. That happened in June, and the merge hook came after. People group push and merge together by meaning, as “confirmed operations that go outside”, but the hook is strung by text, and the gap between meaning and text is where the holes are. What I was worried about then was the hole on the side that lets things through.
What I learned this time is that the same gap opens on the other side. A fence strung by text stops things that are harmless in meaning if the text matches. A drop table inside a comment and a drop table against a database look the same to the hook. Telling them apart would require knowing what happens if the string runs, and a fence that only reads strings doesn’t have that.
The holes on the pass-through side are still there too. Merging via gh api is outside the hook’s scope. The desktop app’s tool for setting auto-merge is another entrance, and a Bash hook never sees it. Every time I widen the text fence to close those, the stops-too-much side widens with it. When I loosened the merge regex in July to catch gh ... pr ... merge in that order, gh pr list --label merge started matching too. That hook’s comment said the same thing as the 25th’s: over-detection only falls to ask, so acceptable. On the 28th I rewrote that one too, to “falls to deny”.
Around the same time, the products fenced by reach
In the week or so around the 28th, a run of announcements aimed at agents came out: local sandboxing in the GitHub Copilot app, Docker’s cloud sandboxes, Google Cloud’s PostgreSQL for agents. Reading the official docs, all of them look at something different from my hooks.
Copilot lets you decide, per project, which folders can be read and written and which are denied, whether outbound network is allowed, and whether Git and gh credentials are handed over. The CLI version goes further and lets you name the hosts a connection may reach. Docker puts the agent in a microVM with its own kernel, keeping your files, network and secrets outside the VM. Google Cloud spins up an isolated PostgreSQL in seconds on the agent’s request, gives it read-only access that tracks production, and scales it back to zero when it’s done.
What they have in common is that none of them read the command. What they look at is how far the process can reach: which folders, which hosts, which credentials, which databases. A drop table inside a comment is nothing to this fence, because it never reads the string. And a drop table against a database that was never handed over can’t get there, whatever the text says.
But what gets done inside that reach, this fence doesn’t look at. Copilot’s docs say the default settings are set up so that ordinary development work, like “pushing a branch and creating a pull request”, can proceed. Once the credentials are handed over, operations within that range go through. The merge I wanted to stop is an operation on my own repository with my own credentials. From the reach fence’s point of view, it’s inside the enclosure. The merge via gh api is the same: inside the enclosure, this fence lets it through as well.
The two answer different questions. The reach fence closes the roads that lead outside, including the ones you didn’t know about. The text fence draws an operational line inside the enclosure: this one a human does. All five of my repeats, the pushes and the merges, happened inside the enclosure.
“Ask” and “stop” turned out to be different parts
A hook that returns ask is only a fence on two assumptions: that someone is there to confirm, and that the confirmation actually appears. In auto mode, the hours with no one there to confirm grow. Whether the confirmation actually appears, my observation disagreed with the docs. The hook fires, returns the right value, and shows up in the log. The fence can stop being a fence in a way you won’t notice even while watching.
Switching to deny brings the fence back, at the cost of my permission no longer working. Now that the hook blocks merge, Claude can’t merge even when I say “merge it”. That isn’t an inconvenience; it’s the point. The three repeats were all cases of my permission or request being stretched to cover a merge, so I removed the permission that could be stretched.
The price is that a fence reading text stops my work too. One line of comment and one line of memo, two stops in 33 seconds. Each time, I either run it myself or write it with a different tool. In place of the effort of pressing a confirmation dialog, I put this effort instead. Even if I added a fence that encloses reach, the line inside the enclosure that says “a human does this” would still be drawn by this text fence. A fence that works in the hours when no one is there to press the button works with the same force in the hours when someone is.
comments powered by Disqus