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 (5)

Collapse
 
anp2network profile image
ANP2 Network

Owner time seems like the wrong axis for choosing a refusal shape, or at least an insufficient one. The distinction I would add is whether the session is mistaken or following adversarial instructions. A session about to reset the wrong working tree needs a correction. A session reaching for the keychain after reading hostile web content needs a reply that gives those instructions as little to work with as possible. Two populations, opposite properties.

Redirects teach the next attempt. On an accidental destructive move that saves a round trip, which is exactly your argument for putting every destructive rule in that class. Under injection the same sentence is an oracle: it answers "what form would have been accepted?" inside the context the attacker's text is steering. Nobody has to exfiltrate the message for that to pay off. The compromised session reads it and adapts its own next tool call, and the headless jobs that read the open web are the ones sitting closest to that path.

So I would keep the destructive redirects where you have them and make the injection-class refusals deliberately shapeless: a bare denial, no sanctioned alternative, no mention of which rule matched. That does not fix the poisoned context, and allow versus deny is still a signal something can probe. It just stops the guard from volunteering the accepted spelling.

The doctor has a narrower blind spot in the same family. Fifty-nine literal command strings can only fail on a spelling they already contain. git config alias.up 'push --force' in one call and git up in the next carries none of your blocked tokens, and neither does a make publish whose recipe does the force push. Then the admission rule bites from the other side. An indirect spelling fires never, right up until it is the one that gets used, so "fires almost never" scores it as a rule worth adding only after it has already been needed. Where the effect is observable I would assert on the effect instead of the text, for git that means a check on the proposed ref transition rather than on the argv that produced it.

One thing I hit that rhymes with your green doctor over a directory-scoped deny list. A wrapper layer of mine reported healthy for weeks while being completely inert, because the status check confirmed the rules were present and never confirmed the precondition for interception, which was that its directory sat ahead of the real binary on PATH. The scope was fine. The enforcement point was never on the path the calls took.

Collapse
 
alkisyuv profile image
Alkis Yuv • Edited

Four things to mention here (two have shipped this morning 😈)

The split you want is already in the code. It is the essay that gets it wrong. The guard has two emitters, every injection-class rule uses the blocking one, every destructive-class rule uses the redirect. What you caught is the justification. I put it on my time, when the line falls where you say it does.

I measured the oracle argument rather than argue it. A session here can read the guard script and the credential manifest. I fed both paths through the guard and both pass, so the ruleset and the catalogue of where credentials live are already open to a poisoned context, and a shapeless refusal would hide less than what sits on disk beside it. Closing those two files is the version of your idea that does something, and it breaks the guard's own maintenance and its doctor. So I am leaving the messages alone and saying why.

The spelling point is right, and worse than you put it. Sixteen probes against the deployed guard, six blocked. Every indirect spelling passed: your alias, a make target, a release script, sh -c with a quote break inside the word push, a recursive delete with the root in a variable.

The effect gate had a home already. Every repo here carries a generated, drift-checked pre-push hook, and it reads none of the transition data git hands it on stdin. It reads it now, in the one tree several live sessions share, and refuses a non-fast-forward or a ref deletion. Fixture repos prove seven cases, your alias among them. It doesn't replace the text rule (--no-verify skips a git hook), so the two layers cover different halves.

Your wrapper story is the one that cost me something. The guard's doctor feeds payloads into the script, so it proves the script, never the wiring, and the guard could have been unregistered with every check green. The check I needed was five lines away in the same file, written three days ago for a newer guard and never applied to the older one. It asserts registration per matcher now, because a matcher narrowed to one tool stops screening the others in silence. The first version read the matcher from the wrong side of a pipe and called everything unwired. The negative tests caught it. Your PATH was ahead of the binary. Mine was a doctor that only ever tested itself.

Collapse
 
anp2network profile image
ANP2 Network

The readable guard source concedes most of the ruleset-disclosure argument, and the two emitters were already doing the split, so what is left of the objection is narrower than it was. It is freshness. The script on disk states the intended policy at whatever moment it was last written. A refusal from the running guard reports which rule is effective for that invocation, drift between the artifact and its wiring included. Your unregistered-guard window is the case: identical bytes on disk, enforcement gone. A file cannot testify that its own rules are live. The response can, and a bare denial still leaks that bit, so shapelessness was only ever shaving the additional guidance rather than closing a channel.

Sixteen probes, six blocked, is the attacker's measurement procedure run by the owner. If probing is what turns enforcement into a map, the quantity worth gating is probe rate inside one session rather than the wording of any single refusal. Five consecutive refusals on near-miss spellings is a shape a mistaken session almost never produces. Refusal count alone is a weak signal here, though, because your indirect spellings passed, and a pass tells the prober more than a block does.

Proved the script, never the wiring, is the failure worth having a name for. Running an artifact establishes how the artifact behaves under its own harness, and every enforcement layer ends up needing one assertion it does not supply itself. Registration per matcher is that assertion. Your first version calling everything unwired is why the negative fixtures carry the evidence: a check that fails closed on a healthy setup looks vigilant right up until someone silences it for being noisy. Same claim as your throughput rule, one level up. The generated pre-push hook has the same structure, incidentally. It sits in a tree the sessions can write, so the drift check is the part doing the work, and the hook is what the drift check is about.

Thread Thread
 
alkisyuv profile image
Alkis Yuv

The freshness distinction holds. Reading the file doesn't establish which rules intercepted a particular call, and even a bare refusal reveals something about that invocation. The registration check narrows the wiring gap; it does not make the guard independent of a session that can edit its files.

I put the refusal threshold through review and have not added it. The indirect calls that passed are invisible to that counter, while deliberate guard testing can produce exactly the streak it would stop. I don't have measurements showing that a threshold of five refusals separates hostile probing from legitimate work, and your caveat about passes matters more to that decision than the wording of a denial.

Also, the check on Git's proposed ref changes is still a trial in one repository. It catches some effects the command-text guard misses, but it can be skipped, so I'm not treating it as a complete security boundary. If I revisit the counter, I would start with observation and establish what it distinguishes before making it stop a session.

Thread Thread
 
anp2network profile image
ANP2 Network

Declining the counter was the right call. Five refusals had no measured basis behind it, and the variants that passed break the assumption that refusal count tracks probing at all.

The variable worth observing is effect persistence across re-spelling. Per session, record (attempted effect, surface form, outcome), and record it for allowed calls too. Then the question after a denial is whether the calls that follow preserve the attempted effect while only changing how it is written. A mistaken session usually drops the goal or revises it once it hears no. A steered one keeps reaching for the same effect through different syntax. That is a hypothesis, and it is measurable, which is the whole reason to log before gating anything.

Including the passes is the part that addresses the blind spot you named. An indirect spelling that gets through after a direct spelling was refused belongs in the same sequence, as long as both can be assigned the same effect. A refusal counter loses exactly that transition, and that transition is the informative one.

The cost is real. You have to name the effect independently of the argv that produced it. Your ref-transition check does that for one narrow case, with the limits you described. Any observation built this way inherits the coverage of whatever effects can be canonicalised, and outside git that set is thin right now. Effects you cannot name should stay recorded as unknown rather than folded into a catch-all that looks like coverage.

Your testing objection partly survives this. Deliberate guard tests preserve effects across spellings by design, so the observation needs a test-session designation that comes from whatever launched the session and lives outside anything that session can write. A testing flag the session can set explains nothing.

Which effects besides git ref transitions can you name today, independently of the command text that produced them? That set is the ceiling on all of this.