DEV Community

Alkis Yuv
Alkis Yuv

Posted on Originally published at dev.yuv.run

My agents run without permission prompts, so the brake moved into the hook

The permission prompt was the last brake on my fleet, and it was in the wrong place. A prompt fires when a human is sitting there to read it. My agents do most of their work when nobody is: the nightly drain, the noon pass, the headless jobs that read the open web. Those run with prompts skipped, by design, because a prompt nobody answers is a stalled job. So the protection was strongest exactly where I was already watching, and absent where the unattended work runs.

What replaced it is a hook. The harness runs a small shell script before every tool call, in every session, in every permission mode, bypass and headless included. The script reads the call as JSON and either lets it through or exits with the code that feeds its message back to the model. Until last week it covered one class: the moves an injected instruction would need, reading a credential file, dumping the keychain, piping a download into a shell. It now covers the class I had left to the prompt: force pushes, a hard reset or a branch swap in the one working tree several live sessions share, a recursive delete aimed at a home or project root, a package release.

The hook exists because of where the old rules lived. One of my contract rules was written in four documents and enforced in one place: a deny list that loads only for a session rooted in a particular directory. Both sessions that broke the rule were rooted somewhere else, so they met no rule at all, while the doctor that checks the setup went green, because it grepped the deny list's text. A rule enforced one directory wide is enforced in the one place the violation was never going to come from. A hook loads everywhere, so it is where a rule that binds every session has to live.

The rule for adding a rule is a throughput rule, not a caution rule. A rule earns its place only if it fires almost never, or if it prevents the kind of cross-session destruction that forces other sessions to redo their work. Anything frequent and recoverable stays out: a plain push, a new branch, a dry-run clean, deleting build output. The test suite has as many passing cases as blocking ones, and the passing ones matter more, because each is a move that happens dozens of times a day, and a false block on any of them costs more than the rule saves.

 move                                     verdict
 git push --force / -f / +ref             refuse, redirect
 git reset --hard, in the shared tree     refuse, redirect
 git checkout main, in the shared tree    refuse, redirect
 rm -rf ~/projects/<repo>                 refuse, redirect
 npm publish                              refuse, owner only
 git push origin main                     pass
 git checkout -b fix/thing                pass
 git reset --hard, in a solo repo         pass
 rm -rf node_modules                      pass
 rm -rf <repo>/dist/assets                pass
Enter fullscreen mode Exit fullscreen mode

Two shapes of refusal, and the difference is my time. A block ends with "the owner runs it", which serialises a parallel session onto the scarcest resource on the machine, so it is reserved for the things only I can do: a credential, a release. A redirect refuses the move and names the sanctioned one in the same breath, so the session corrects itself and keeps going, no human in the loop, no wall-clock lost. Every rule in the destructive class is a redirect.

REFUSED (guard): git push --force rewrites history other sessions
may have built on. Push to a new branch and open a merge request;
gate-only ones merge on their own.
This is not an owner-permission question: take the sanctioned path
above and carry on.
Enter fullscreen mode Exit fullscreen mode

That message is what the model reads. It never reaches me, and it does not need to.

- A refusal that stops a session and waits for me is the most expensive event on the machine. One that redirects it is free.

The guard has a doctor. It feeds 59 synthetic tool calls through itself and checks every verdict, because the harness that calls it has renamed its fields before. If a field the guard reads changes name, the known-bad cases stop blocking, and the doctor fails loudly instead of the guard silently permitting everything. The false-positive half of that suite caught three bugs before the rules landed: a repetition operator from the wrong regex dialect, a separator consumed twice so the recursive clean never matched, and a commit message scanned as command text, which made the guard refuse its own landing commit because the message contained a table of the moves it refuses. Heredoc bodies are prose now.

One more thing learned the hard way. The guard is parsed before every tool call in every session on the machine, so a syntax error in it blocks every tool, including the one that would fix it. A stray quote did exactly that two days before the new rules, and I restored the file by hand. Its header now says: write the new version to scratch, syntax-check it, run its doctor, and only then copy it over.

With the brake in the hook, the prompt became optional, so I turned it off. Bypass is now the default permission mode in every interactive session, and the two headless jobs declare that mode explicitly instead of inheriting whatever the ambient setting happens to be. I had refused the same change earlier that day, on the ground that it removed a brake nothing replaced. The order matters: cover the class first, then drop the prompt.

There is one escape, and it is not a grant. A guard exists to stop a session acting on its own judgment, not to stand between me and my machine. So the escape is me typing the command in my own terminal, where no hook runs. A spoken go-ahead in the conversation does not unblock the tool call, and must never be treated as a reason to retry or reword one. Any signal a session could write to grant itself passage turns the refusal back into a reminder, and a reminder is exactly what failed twice before the hook existed.

The prompt used to ask whether I was sure. The hook doesn't ask. It already knows which moves I was never sure about.

Top comments (0)