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

> Source: <https://dev.to/rabih_jabr_29/your-agent-isnt-reckless-it-just-cant-see-the-blast-radius-1lkj>
> Published: 2026-08-20 18:29:21+00:00

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.

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:

`git push --force origin main`

.`rm -rf "$BUILD_DIR/"`

runs on the one machine where `BUILD_DIR`

never got set.`package-lock.json`

, because that's the file the version number is visibly in.`.skip`

and CI goes green.`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.

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"
  }
}
```

And you can refuse it:

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

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.

They live here: [claude-guardrails](https://github.com/RabihJabr29/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.

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
```

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

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' });
```

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.

`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' })
```

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.

`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.

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' } }
  ]
}
```

`--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.

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.

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 */ ]
  }
};
```

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

`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`

on a box that's also your build cache.

prune -a`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](https://github.com/RabihJabr29/claude-guardrails)
