{"slug": "your-agent-isn-t-reckless-it-just-can-t-see-the-blast-radius", "title": "Your agent isn't reckless. It just can't see the blast radius.", "summary": "Rabih Jabr, a developer, reports that AI coding agents like Claude Code can make locally correct decisions with non-local consequences, such as force-pushing to main, because they cannot see the full impact. He proposes using PreToolUse hooks to deny dangerous actions and provide instructive feedback, and he has open-sourced a set of guardrails on GitHub.", "body_md": "I've been running Claude Code as a daily driver for about three months now. It writes\n\nAnsible I'd have taken a week to write. It reads a codebase faster than I do. It is, genuinely, very good.\n\nIt also once wanted to force-push to `main`\n\n, and it wanted to for an extremely good\n\nreason.\n\nSit with that for a second, because it's the whole post.\n\nThe rebase was stuck. Force-pushing would have unstuck it. Every link in that chain of\n\nreasoning is sound. The agent wasn't being careless, wasn't hallucinating, wasn't\n\n\"drifting\" or whatever we're calling it this month. It made a locally correct decision\n\nwith a non-local consequence, which is the exact category of mistake that human code\n\nreview is worst at catching — because the diff looks *fine*.\n\nIt could see the command. It could not see the crater.\n\nFor a while my answer was to read everything. Every diff, every command, eyes on the\n\nscreen, hand hovering over Ctrl-C like a man watching a toddler near a staircase.\n\nThis does not scale, and the reason it doesn't is embarrassing when you say it out loud:\n\n**reviewing output scales with how much the agent writes.** That number is going exactly\n\none direction, and it isn't down.\n\nSo I flipped it. Instead of reviewing what it produces, I started writing down what it\n\nmust never do.\n\nAnd here's the good news that took me way too long to notice: that list is *short*. Not\n\n\"short for a security policy\" short. Short like you can fit it on a napkin.\n\nHere's mine:\n\n`git push --force origin main`\n\n.`rm -rf \"$BUILD_DIR/\"`\n\nruns on the one machine where `BUILD_DIR`\n\nnever got set.`package-lock.json`\n\n, because that's the file the version number is visibly in.`.skip`\n\nand CI goes green.`cat .env`\n\n\"just to see which variables exist.\"That last one is my favourite, and I'll come back to it.\n\nNone of these are the agent being stupid. Every single one is a reasonable move by\n\nsomething that can't see two feet past the command it's about to run.\n\nThis is the part I think a lot of people don't know exists.\n\n`PreToolUse`\n\nis a hook that fires *before* any tool call. Your script gets the whole\n\nthing on stdin:\n\n```\n{\n  \"session_id\": \"abc123\",\n  \"cwd\": \"/home/rabih/app\",\n  \"hook_event_name\": \"PreToolUse\",\n  \"tool_name\": \"Bash\",\n  \"tool_input\": {\n    \"command\": \"git push --force origin main\"\n  }\n}\n```\n\nAnd you can refuse it:\n\n```\n{\n  \"hookSpecificOutput\": {\n    \"hookEventName\": \"PreToolUse\",\n    \"permissionDecision\": \"deny\",\n    \"permissionDecisionReason\": \"This force-pushes to `main`, a shared branch.\"\n  }\n}\n```\n\nNow, the bit that genuinely surprised me.\n\nThat `permissionDecisionReason`\n\nstring? The agent *reads* it. And acts on it.\n\nSay \"blocked\" and it shrugs and retries with slightly different syntax, like a cat testing\n\na closed door. Say \"change the manifest and run `pnpm add`\n\n\" and it goes and does that,\n\nfirst try, no argument.\n\nWhich reframes the whole thing. A denial isn't just a fence. It's the highest\n\nsignal-to-noise teaching moment you will ever get, because it lands at the precise second\n\nthe agent was about to be wrong. Nobody reads documentation at that moment. Everybody\n\nreads an error.\n\nSo every guard I wrote has to answer two questions, not one: what's wrong, and what to do\n\ninstead.\n\nThey live here: [claude-guardrails](https://github.com/RabihJabr29/claude-guardrails)\n\n| Guard | Blocks |\n|---|---|\n`secrets-never-land-in-source` |\nCredential-shaped literals written into source |\n`secret-files-stay-out-of-context` |\nReading `.env` , `*.pem` , `~/.aws/credentials` into the session |\n`secrets-are-not-staged` |\n`git add -A` in a repo where `.env` was never gitignored |\n`shared-branches-are-not-rewritten` |\n`git push --force` to `main` , `develop` , `release/*`\n|\n`uncommitted-work-is-not-discarded` |\n`git reset --hard` , `git clean -fd` , `git stash drop`\n|\n`verification-hooks-are-not-bypassed` |\n`--no-verify` , `HUSKY=0` , `--no-gpg-sign`\n|\n`unexpanded-variables-in-destructive-paths` |\n`rm -rf \"$DIR/\"` where `$DIR` could be empty |\n`remote-code-is-not-piped-to-a-shell` |\n`curl … \\ |\n{% raw %}`committed-migrations-are-immutable`\n|\nEditing a migration that's already committed |\n`destructive-sql-needs-a-where` |\nUnbounded `DELETE` /`UPDATE` , ad-hoc `TRUNCATE`\n|\n`cluster-targets-are-explicit` |\nDestructive `kubectl` with no `--context`\n|\n`tests-are-not-silenced` |\nIntroducing `.skip` , `@Disabled` , `continue-on-error: true`\n|\n`lockfiles-are-generated-not-edited` |\nHand-editing `package-lock.json` and friends |\n\nZero dependencies. Nothing to configure. Node reading a JSON payload and occasionally\n\nsaying no.\n\nFour of them turned out more interesting than I expected when I started writing them.\n\nMy first instinct was to guard the *write* — stop the key from landing in a file.\n\nThen I thought about it for another minute and realised I had it backwards.\n\nThe write path has a code review in front of it. Someone, eventually, looks at that diff.\n\nThe read path has *nothing*. When an agent runs `cat .env`\n\nto check which variables\n\nexist, it gets a completely reasonable answer to a completely reasonable question — and\n\nevery value in that file is now sitting in a transcript. Transcripts get stored. Synced.\n\nOccasionally pasted into a bug report by someone being helpful.\n\nNothing changed on disk. `git diff`\n\nis empty. And your credentials have left the building.\n\nSo the guard blocks the read and suggests this instead:\n\n```\ngrep -o \"^[A-Z_]*=\" .env\n```\n\nSame question, answered, minus the part that ruins your week.\n\nI wanted a guard that stops you editing a migration a database has already run.\n\nSmall problem: a hook has no idea what your production database has run. It's a Node\n\nscript with a JSON blob. It cannot phone Postgres.\n\nBut it can ask git one question:\n\n```\nexecFileSync('git', ['ls-files', '--error-unmatch', '--', pathspec], { cwd, stdio: 'ignore' });\n```\n\nIs this file tracked? That's it. That's the whole heuristic — and it's a *good* one,\n\nbecause once a migration is committed, something somewhere has almost certainly run it.\n\nThe lovely side effect: the migration you're still drafting is untracked, so the guard is\n\ninvisible while you're writing and immovable the moment you're not. The git index draws\n\nthat line for free, and I didn't have to invent a single config option to get it.\n\n`git add`\n\nis the one that looks harmless\n`git add .env`\n\nis fine, honestly. It's *visible*. It's right there in the scrollback,\n\nyou'd catch it.\n\n`git add -A`\n\nin a repo where nobody remembered to gitignore `.env`\n\n— that stages it\n\nsilently alongside forty other files, and then the commit message says \"add feature\", and\n\nnobody looks, and it's on GitHub.\n\nSo this guard doesn't pattern-match the command at all. It asks git what a blanket add\n\nwould actually pick up:\n\n```\nexecFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, encoding: 'utf8' })\n```\n\nHere's the part I'm quietly pleased about: gitignored files never show up in that output.\n\nWhich means on a correctly configured repo, this guard is completely, permanently silent.\n\nIt only ever speaks to the repos that have the problem.\n\nA guard nobody notices is a guard nobody uninstalls. That property is worth more than the\n\ncheck.\n\n`kubectl delete pod api-7f9d`\n\n.\n\nWhich cluster is that? I don't know. You don't know. The agent doesn't know. The hook\n\n*definitely* doesn't know, because the answer lives in a config file that the payload\n\nnever carries.\n\nEvery other guard in this repo reads intent off the tool call. This one can't. So it does\n\nthe only honest thing available: it refuses until you write `--context`\n\nand make the\n\ncommand say out loud what it's about to change.\n\nIt isn't blocking a mistake. It's blocking an *ambiguity* — a command whose transcript\n\nwon't record what it did. I think it might be the most useful one in the set, and it's\n\nthe only one that works by admitting it can't see.\n\nIf this repo works at all, most of the guards in it will eventually be written by\n\nstrangers. Which changes the design problem completely.\n\n**A broken guard must never block a tool call.** Someone will ship a bug. If their bug\n\ntakes down my `git push`\n\n, this whole idea dies. So every guard runs in its own\n\n`try/catch`\n\n, and a throw is treated as \"no opinion\" with a grumble on stderr.\n\nYes — that means a crashing guard fails *open*. For a security tool that sounds\n\nindefensible right up until you picture the alternative: one bad merge and nobody on\n\nearth can commit until it's reverted. The plugin gets deleted, and a deleted plugin\n\nguards nothing. Fail-open keeps it installed. Installed is the entire game.\n\n**Silence means allow.** The dispatcher only ever emits JSON to *deny*. `permissionDecision`\n\nwill happily accept `\"allow\"`\n\n, which would stomp on your own permission settings — and\n\nthis plugin has no business doing that. It gets one vote. The vote is \"no\".\n\n**Precision beats recall, and it isn't close.** One false positive on correct work and the\n\nplugin is gone by lunchtime.\n\nSo every guard ships its near misses as executable examples:\n\n```\nexamples: {\n  blocked: [\n    { tool_name: 'Bash', tool_input: { command: 'git push --force origin main' } }\n  ],\n  allowed: [\n    { tool_name: 'Bash', tool_input: { command: 'git push --force-with-lease origin main' } },\n    { tool_name: 'Bash', tool_input: { command: 'git push --force origin feature/x' } }\n  ]\n}\n```\n\n`--force-with-lease`\n\nagainst `--force`\n\n. `.env.example`\n\nagainst `.env`\n\n.\n\n`docs/package-lock.md`\n\nagainst `package-lock.json`\n\n. That's where false positives live, so\n\nthat's what you have to write down.\n\nAnd those examples *are* the test suite. `npm test`\n\nwalks every guard and asserts both\n\nlists.\n\nThat was the design decision I'm happiest with, and it took the longest to see. The\n\nobvious version of this repo has a `guards/`\n\nfolder and a `test/`\n\nfolder and contributors\n\nwrite both. Except they don't. Nobody writes the second folder. Ever.\n\nFolding the tests into the guard definition means a contribution is one file — and that\n\nfile isn't valid until you've stated, in code, what it deliberately lets through.\n\nThese are Node, not shell.\n\nThe shell versions are about a third the length and would depend on `jq`\n\n. I wrote this on\n\nWindows. A meaningful chunk of the people who'd want it aren't sitting in a Unix shell,\n\nand a guardrail that only protects developers who already have good tooling is a fairly\n\nuseless guardrail.\n\nNode ships with Claude Code. The dependency is already paid for.\n\nIncidentally the whole plugin has zero dependencies, so it has no lockfile — which is a\n\ngenuinely funny property for a project that ships a lockfile guard.\n\nThe unit of contribution is one file. Copy `guards/_template.js`\n\n, change five things,\n\nopen a PR. Ten minutes, tops.\n\n```\nmodule.exports = {\n  id: 'your-guard-id',\n  title: 'Short statement of the rule',\n  prevents: 'The specific thing that goes wrong when nobody is watching.',\n  tools: ['Bash'],\n  check(input) {\n    // return { reason } to deny, or null to stay out of the way\n  },\n  examples: {\n    blocked: [ /* payloads that must deny */ ],\n    allowed: [ /* payloads that must pass */ ]\n  }\n};\n```\n\nDrop it in `guards/`\n\n. It's live. There's no registry to update — the dispatcher just\n\nreads the directory.\n\nOne field decides whether it merges, and it's `prevents`\n\n.\n\n\"It's bad practice\" is not a `prevents`\n\n. If you can't finish the sentence *\"the last time\nthis happened, what broke was…\"*, you've got a style preference, and style preferences\n\n`CLAUDE.md`\n\n.Which brings me to why I stopped at thirteen.\n\nI can see the shape of four more. `terraform apply`\n\nwith no plan file. `docker system`\n\non a box that's also your build cache.\n\nprune -a`chmod -R 777`\n\nas a debugging step that\n\nsomehow never gets reverted. An `ALTER TABLE`\n\nthat takes a lock on fifty million rows.\n\nI have opinions about all four. I have incidents behind none of them.\n\nThat's the wrong ratio for writing a guard, because the `prevents`\n\nfield would be a\n\nguess — and a guess is exactly how you end up with a rule that fires on correct work and\n\ngets the whole thing uninstalled.\n\nThirteen is where I ran out of scars. It is not where the list ends.\n\n**If you've got the scar, write the guard.**\n\n→ [github.com/RabihJabr29/claude-guardrails](https://github.com/RabihJabr29/claude-guardrails)", "url": "https://wpnews.pro/news/your-agent-isn-t-reckless-it-just-can-t-see-the-blast-radius", "canonical_source": "https://dev.to/rabih_jabr_29/your-agent-isnt-reckless-it-just-cant-see-the-blast-radius-1lkj", "published_at": "2026-08-20 18:29:21+00:00", "updated_at": "2026-08-20 18:44:20.105464+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools", "artificial-intelligence"], "entities": ["Claude Code", "Ansible", "Rabih Jabr", "GitHub", "claude-guardrails"], "alternates": {"html": "https://wpnews.pro/news/your-agent-isn-t-reckless-it-just-can-t-see-the-blast-radius", "markdown": "https://wpnews.pro/news/your-agent-isn-t-reckless-it-just-can-t-see-the-blast-radius.md", "text": "https://wpnews.pro/news/your-agent-isn-t-reckless-it-just-can-t-see-the-blast-radius.txt", "jsonld": "https://wpnews.pro/news/your-agent-isn-t-reckless-it-just-can-t-see-the-blast-radius.jsonld"}}