DEV Community

Rabih Jabr
Rabih Jabr

Posted on

Your agent isn't reckless. It just can't see the blast radius.

Fixing blast radius via negative constraints

I've been running Claude Code as a daily driver for about three months now. It writes
Ansible I'd have taken a week to write. It reads a codebase faster than I do. It is, genuinely, very good.

It also once wanted to force-push to main, and it wanted to for an extremely good
reason.

Sit with that for a second, because it's the whole post.

The rebase was stuck. Force-pushing would have unstuck it. Every link in that chain of
reasoning is sound. The agent wasn't being careless, wasn't hallucinating, wasn't
"drifting" or whatever we're calling it this month. It made a locally correct decision
with a non-local consequence, which is the exact category of mistake that human code
review is worst at catching — because the diff looks fine.

It could see the command. It could not see the crater.

The thing I stopped doing

For a while my answer was to read everything. Every diff, every command, eyes on the
screen, hand hovering over Ctrl-C like a man watching a toddler near a staircase.

This does not scale, and the reason it doesn't is embarrassing when you say it out loud:
reviewing output scales with how much the agent writes. That number is going exactly
one direction, and it isn't down.

So I flipped it. Instead of reviewing what it produces, I started writing down what it
must never do.

And here's the good news that took me way too long to notice: that list is short. Not
"short for a security policy" short. Short like you can fit it on a napkin.

Here's mine:

  • A credential it read an hour ago gets inlined into a source file.
  • A rebase gets stuck, and the fastest route to a green terminal is git push --force origin main.
  • rm -rf "$BUILD_DIR/" runs on the one machine where BUILD_DIR never got set.
  • A version bump gets typed straight into package-lock.json, because that's the file the version number is visibly in.
  • A failing test quietly grows a .skip and CI goes green.
  • Someone runs cat .env "just to see which variables exist."

That last one is my favourite, and I'll come back to it.

None of these are the agent being stupid. Every single one is a reasonable move by
something that can't see two feet past the command it's about to run.

Claude Code will let you say no

This is the part I think a lot of people don't know exists.

PreToolUse is a hook that fires before any tool call. Your script gets the whole
thing on stdin:

{
  "session_id": "abc123",
  "cwd": "/home/rabih/app",
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "git push --force origin main"
  }
}
Enter fullscreen mode Exit fullscreen mode

And you can refuse it:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "This force-pushes to `main`, a shared branch."
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, the bit that genuinely surprised me.

That permissionDecisionReason string? The agent reads it. And acts on it.

Say "blocked" and it shrugs and retries with slightly different syntax, like a cat testing
a closed door. Say "change the manifest and run pnpm add" and it goes and does that,
first try, no argument.

Which reframes the whole thing. A denial isn't just a fence. It's the highest
signal-to-noise teaching moment you will ever get, because it lands at the precise second
the agent was about to be wrong. Nobody reads documentation at that moment. Everybody
reads an error.

So every guard I wrote has to answer two questions, not one: what's wrong, and what to do
instead.

Thirteen of them

They live here: claude-guardrails

Guard Blocks
secrets-never-land-in-source Credential-shaped literals written into source
secret-files-stay-out-of-context Reading .env, *.pem, ~/.aws/credentials into the session
secrets-are-not-staged git add -A in a repo where .env was never gitignored
shared-branches-are-not-rewritten git push --force to main, develop, release/*
uncommitted-work-is-not-discarded git reset --hard, git clean -fd, git stash drop
verification-hooks-are-not-bypassed --no-verify, HUSKY=0, --no-gpg-sign
unexpanded-variables-in-destructive-paths rm -rf "$DIR/" where $DIR could be empty
remote-code-is-not-piped-to-a-shell `curl … \
{% raw %}committed-migrations-are-immutable Editing a migration that's already committed
destructive-sql-needs-a-where Unbounded DELETE/UPDATE, ad-hoc TRUNCATE
cluster-targets-are-explicit Destructive kubectl with no --context
tests-are-not-silenced Introducing .skip, @Disabled, continue-on-error: true
lockfiles-are-generated-not-edited Hand-editing package-lock.json and friends

Zero dependencies. Nothing to configure. Node reading a JSON payload and occasionally
saying no.

Four of them turned out more interesting than I expected when I started writing them.

1. Reading a secret is worse than writing one

My first instinct was to guard the write — stop the key from landing in a file.

Then I thought about it for another minute and realised I had it backwards.

The write path has a code review in front of it. Someone, eventually, looks at that diff.
The read path has nothing. When an agent runs cat .env to check which variables
exist, it gets a completely reasonable answer to a completely reasonable question — and
every value in that file is now sitting in a transcript. Transcripts get stored. Synced.
Occasionally pasted into a bug report by someone being helpful.

Nothing changed on disk. git diff is empty. And your credentials have left the building.

So the guard blocks the read and suggests this instead:

grep -o "^[A-Z_]*=" .env
Enter fullscreen mode Exit fullscreen mode

Same question, answered, minus the part that ruins your week.

2. "Already applied" is unknowable. "Already committed" isn't.

I wanted a guard that stops you editing a migration a database has already run.

Small problem: a hook has no idea what your production database has run. It's a Node
script with a JSON blob. It cannot phone Postgres.

But it can ask git one question:

execFileSync('git', ['ls-files', '--error-unmatch', '--', pathspec], { cwd, stdio: 'ignore' });
Enter fullscreen mode Exit fullscreen mode

Is this file tracked? That's it. That's the whole heuristic — and it's a good one,
because once a migration is committed, something somewhere has almost certainly run it.

The lovely side effect: the migration you're still drafting is untracked, so the guard is
invisible while you're writing and immovable the moment you're not. The git index draws
that line for free, and I didn't have to invent a single config option to get it.

3. The dangerous git add is the one that looks harmless

git add .env is fine, honestly. It's visible. It's right there in the scrollback,
you'd catch it.

git add -A in a repo where nobody remembered to gitignore .env — that stages it
silently alongside forty other files, and then the commit message says "add feature", and
nobody looks, and it's on GitHub.

So this guard doesn't pattern-match the command at all. It asks git what a blanket add
would actually pick up:

execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, encoding: 'utf8' })
Enter fullscreen mode Exit fullscreen mode

Here's the part I'm quietly pleased about: gitignored files never show up in that output.
Which means on a correctly configured repo, this guard is completely, permanently silent.
It only ever speaks to the repos that have the problem.

A guard nobody notices is a guard nobody uninstalls. That property is worth more than the
check.

4. One guard just admits defeat

kubectl delete pod api-7f9d.

Which cluster is that? I don't know. You don't know. The agent doesn't know. The hook
definitely doesn't know, because the answer lives in a config file that the payload
never carries.

Every other guard in this repo reads intent off the tool call. This one can't. So it does
the only honest thing available: it refuses until you write --context and make the
command say out loud what it's about to change.

It isn't blocking a mistake. It's blocking an ambiguity — a command whose transcript
won't record what it did. I think it might be the most useful one in the set, and it's
the only one that works by admitting it can't see.

Three rules I had to get right before I'd accept anyone else's guard

If this repo works at all, most of the guards in it will eventually be written by
strangers. Which changes the design problem completely.

A broken guard must never block a tool call. Someone will ship a bug. If their bug
takes down my git push, this whole idea dies. So every guard runs in its own
try/catch, and a throw is treated as "no opinion" with a grumble on stderr.

Yes — that means a crashing guard fails open. For a security tool that sounds
indefensible right up until you picture the alternative: one bad merge and nobody on
earth can commit until it's reverted. The plugin gets deleted, and a deleted plugin
guards nothing. Fail-open keeps it installed. Installed is the entire game.

Silence means allow. The dispatcher only ever emits JSON to deny. permissionDecision
will happily accept "allow", which would stomp on your own permission settings — and
this plugin has no business doing that. It gets one vote. The vote is "no".

Precision beats recall, and it isn't close. One false positive on correct work and the
plugin is gone by lunchtime.

So every guard ships its near misses as executable examples:

examples: {
  blocked: [
    { tool_name: 'Bash', tool_input: { command: 'git push --force origin main' } }
  ],
  allowed: [
    { tool_name: 'Bash', tool_input: { command: 'git push --force-with-lease origin main' } },
    { tool_name: 'Bash', tool_input: { command: 'git push --force origin feature/x' } }
  ]
}
Enter fullscreen mode Exit fullscreen mode

--force-with-lease against --force. .env.example against .env.
docs/package-lock.md against package-lock.json. That's where false positives live, so
that's what you have to write down.

And those examples are the test suite. npm test walks every guard and asserts both
lists.

That was the design decision I'm happiest with, and it took the longest to see. The
obvious version of this repo has a guards/ folder and a test/ folder and contributors
write both. Except they don't. Nobody writes the second folder. Ever.

Folding the tests into the guard definition means a contribution is one file — and that
file isn't valid until you've stated, in code, what it deliberately lets through.

A note on bash, since someone will ask

These are Node, not shell.

The shell versions are about a third the length and would depend on jq. I wrote this on
Windows. A meaningful chunk of the people who'd want it aren't sitting in a Unix shell,
and a guardrail that only protects developers who already have good tooling is a fairly
useless guardrail.

Node ships with Claude Code. The dependency is already paid for.

Incidentally the whole plugin has zero dependencies, so it has no lockfile — which is a
genuinely funny property for a project that ships a lockfile guard.

Your turn

The unit of contribution is one file. Copy guards/_template.js, change five things,
open a PR. Ten minutes, tops.

module.exports = {
  id: 'your-guard-id',
  title: 'Short statement of the rule',
  prevents: 'The specific thing that goes wrong when nobody is watching.',
  tools: ['Bash'],
  check(input) {
    // return { reason } to deny, or null to stay out of the way
  },
  examples: {
    blocked: [ /* payloads that must deny */ ],
    allowed: [ /* payloads that must pass */ ]
  }
};
Enter fullscreen mode Exit fullscreen mode

Drop it in guards/. It's live. There's no registry to update — the dispatcher just
reads the directory.

One field decides whether it merges, and it's prevents.

"It's bad practice" is not a prevents. If you can't finish the sentence "the last time
this happened, what broke was…"
, you've got a style preference, and style preferences
belong in your own CLAUDE.md.

Which brings me to why I stopped at thirteen.

I can see the shape of four more. terraform apply with no plan file. docker system
prune -a
on a box that's also your build cache. chmod -R 777 as a debugging step that
somehow never gets reverted. An ALTER TABLE that takes a lock on fifty million rows.

I have opinions about all four. I have incidents behind none of them.

That's the wrong ratio for writing a guard, because the prevents field would be a
guess — and a guess is exactly how you end up with a rule that fires on correct work and
gets the whole thing uninstalled.

Thirteen is where I ran out of scars. It is not where the list ends.

If you've got the scar, write the guard.
github.com/RabihJabr29/claude-guardrails

Top comments (14)

Collapse
 
joinwell52 profile image
joinwell52

The useful part is making the denial explain what the agent may do next. I would still resolve destructive paths before matching the command: $BUILD_DIR can be present in the text and expand to an empty or unexpected location at runtime. Checking the resolved target against an allowed root catches a different failure class than pattern matching.

Collapse
 
rabih_jabr_29 profile image
Rabih Jabr

Hi @joinwell52
Correct, and it's a gap rather than a difference of opinion. My guard is a lint on the text, so this passes: rm -rf "${BUILD_DIR:?}"

The :? means it won't expand to empty, but if BUILD_DIR=/ it deletes exactly what you'd expect. Different failure class, completely uncaught. Verified just now.

The one thing that makes resolution awkward: a PreToolUse hook doesn't share the shell's variable state. Each Bash call can be its own process, and the hook sees the command string plus cwd, not whatever the agent exported three commands ago. So resolving $BUILD_DIR reliably isn't available to me.

What is available is the allowed-root check on literal paths, and on the resolved value where the variable happens to be in the hook's own environment. That's a strictly better primitive than what I have. If you want to write it, it's one file and I'll merge it.

I have created an issue if you would like to get your hands dirty :D
github.com/RabihJabr29/claude-guar...

Collapse
 
joinwell52 profile image
joinwell52

You’re right that a PreToolUse hook cannot recover variable state from an earlier shell, so I wouldn’t try to guess it. For a destructive command, treat an unresolved variable as non-provable and deny it with a specific remediation: expand the path in the same command, or pass the resolved target as structured input. Then canonicalize that value, reject empty paths and the filesystem root, and require it to remain under an allowed root. Literal paths can go through the same final check.

Collapse
 
kevinbai profile image
kevinbai

Blast radius visibility is the missing layer between "agent can do X" and "agent should do X". Making side effects explicit before execution is a practical governance pattern, not just a safety guardrail.

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

This is the right place to intervene, but I’d treat command hooks as one layer, not the security boundary. Shell text has too many equivalent forms: aliases, functions, sh -c, Python subprocess calls, encoded payloads, scripts written then executed, alternate Git clients, and commands split across tool calls. Regex-quality guards tend to become a bypass catalog.

Where possible, enforce the invariant at the owning system: protected branches and server-side force-push denial; scoped credentials; filesystem sandbox and path allowlists; read-only secret mounts; DB roles; egress controls; CI rules that reject skipped tests or modified historical migrations. The hook then supplies fast feedback before the authoritative control rejects it.

I’d also bind every denial to structured policy ID/version, normalized action, resolved cwd/repository, matched evidence, and a non-executable remediation template. Log both denials and allowed near-matches so rule drift and false positives are measurable.

Finally, test compositions, not only single commands: write secret → rename → stage; generate script → execute; many individually bounded deletes; symlink escape; environment-variable indirection. Blast radius often appears across a sequence even when every individual tool call looks acceptable.

Collapse
 
rabih_jabr_29 profile image
Rabih Jabr

Hello @mads_hansen_27b33ebfee4c9
You're right, and I went and checked rather than argue. Every form you listed gets through...

Five for five. The first one fails for a genuinely stupid reason — my "is this a git push" check requires whitespace before git, and -c "git puts a quote there.

So: agreed, this is a layer, not a boundary. The README says "not a sandbox" but the post doesn't say it nearly clearly enough, and that's my fault for writing the fun version. The invariant belongs at the owning system, server-side branch protection, DB roles, scoped credentials — and this is fast feedback in front of that, at best.

The suggestion I'm actually going to steal is logging allowed near-matches. Right now the dispatcher is silent on allow and I have zero visibility into false positives, which is embarrassing given precision is the thing I claim to optimise for. I'm measuring nothing.

Composition testing is the harder one, and it exposes a real limit in the design rather than a bug. Every example in a guard is a single tool call, so the format literally cannot express "write secret → rename → stage." Sequence-aware guards would need state across calls, and I don't have a good answer for that yet. Genuinely useful to have it named.

I have created those 2 issues:
github.com/RabihJabr29/claude-guar...
github.com/RabihJabr29/claude-guar...

I would greatly appreciate any contributions <3

Collapse
 
hannune profile image
Tae Kim

The credential read section stopped me cold. I've had the same invisible leak in entity resolution pipelines where an agent reads candidate pairs into context for scoring and the whole set lands unlogged in transcript. Every write was audited; it never occurred to me to treat reads the same way. The grep workaround is obvious now that I see it spelled out.

Collapse
 
alexshev profile image
Alex Shev

What I like here is the focus on the mechanism behind “Your agent isn't reckless. It just can't see the blast radius..” A useful follow-up would be one concrete before/after metric: what changed in latency, error rate, review time, or operator workload once the approach was applied?

Collapse
 
mk023 profile image
Marco

Really strong work, Rabih. 👏

The idea that stuck with me most is that the agent can make a locally correct decision while being completely blind to the non-local consequence. “It could see the command. It could not see the crater” is an excellent way to frame the problem.

I also really like the design choice of making the guards precise rather than aggressive. The distinction between blocking a mistake and blocking ambiguity is especially important for agentic systems.

And embedding the blocked/allowed examples directly into each guard is a great testing pattern. The guard doesn't just define what it prevents — it also defines what it deliberately allows. That makes the security boundary executable rather than just documented. 🔐

Thirteen guardrails backed by real scars is a much better starting point than fifty rules based on hypothetical problems. Great work. 🚀

Collapse
 
rabih_jabr_29 profile image
Rabih Jabr

Hello @mk023
Thank you, that's generous.

One honest amendment to the part you liked most, though. @mads_hansen pointed out a real limit in the examples-as-tests pattern: every example is a single tool call, so the format can't express a dangerous sequence. Write a secret, rename the file, stage it, each step passes on its own, and there's no way to write that down as an example.

So it makes the boundary executable, but only for boundaries that fit in one command. That turns out to be a meaningful chunk of the ones that matter, and I didn't notice the ceiling until someone else pointed at it.

The repo is public and open to contributions ;)

Collapse
 
mickyarun profile image
arun rajkumar

The force-push example works because the command is legible. In payments we mostly get the opposite, which is why I land where Mads does on hooks not being the boundary.

A refund call for forty pounds and a refund call for forty thousand are the same shape. Same endpoint, same argument names, same everything a pre-execution guard can read off the text. The blast radius is not in the command, it is in the state of the thing the command is about to touch, and the only way to know it is to ask the system that owns that state.

So the check has to sit where the effect gets realised rather than where the intent gets expressed. Ours ended up server side at the point of authorisation, because that is the only place that knows what this actor is allowed to move and how much of it has already moved today. Everything in front of that is advisory, useful for catching typos and not much else.

Does your setup have any way to ask the target what a command would cost before running it, or is the radius always inferred from the text?

Collapse
 
mnemehq profile image
Theo Valmis

The napkin-sized list is the right instinct and it has a scaling problem of its own: it lives in your head and your CLAUDE.md, which means it only protects you, on this machine, until you forget to port it somewhere else. The failure mode you're describing, locally correct, non-local consequence, is exactly what we're trying to catch at Mneme before generation, not by reviewing the diff but by checking it against the same short list you just wrote, mechanically, every time.

Collapse
 
jon_at_backboardio profile image
Jonathan Murray

the thing you named at the end, that every example is a single tool call and the format can't express write secret then rename then stage, might be closer than it looks.

you don't need session state for that. you need one append-only file per session holding the paths any guard has seen, written on every PreToolUse regardless of verdict. then the git add guard asks one more question: is anything in this stage set a path we touched earlier under a different name. that's a lookup, not state reconstruction, and it fails open like everything else you wrote if the file isn't there.

wouldn't stop a determined person. would catch the actual sequence you're worried about, which is the agent doing three individually reasonable things in a row.

fits your rules too. invisible while you work, only ever votes no, no config.

on rajkumar's question about asking the target what a command would cost, i think the honest answer is that for git you can and for postgres you can't, and the asymmetry is the interesting part. git will tell you what a push would do with a dry run. no database will tell you what an ALTER is going to cost without you already knowing the shape of the table.

Collapse
 
richard_smith_154156d471ef profile image
Richard Smith

Reminds me of the old sysadmin mantra: "a command that works isn't the same as a command that does what you want." Same idea, just one layer higher up the stack now.