Fail-closed guardrails for Claude Code (and any agent that runs shell commands and edits files through hooks). Two PreToolUse hooks, one policy file, an audit log, a monthly report, and a test suite that proves every block.
./tests/run_tests.sh # 104 assertions, all green
Built after an evening of breaking my own deny-list. The story is in the write-up, the short version is below.
| Layer | Rule | Examples that are denied |
|---|---|---|
| Protected paths | A command that references a protected path is denied unless every verb in it is on a short read-only allow-list | rm, mv, cp, tee, sed -i, dd, rsync, find -delete, sh -c "rm ...", P=protected; rm $P/x, (cd protected && rm x), echo x >| protected/x, symlink and ../ dodges |
| File writes | Write, Edit, MultiEdit and NotebookEdit are denied inside protected paths and outside the allowed tree; every path key in the payload is inspected, including nested edits[] and notebook_path | writes into protected/, writes to /tmp, payloads with no readable path (fails closed) |
| Destructive patterns | Regexes on the raw command, checked before any path logic | git push --force, git reset --hard, git clean -f, git checkout -- ., git branch -D, curl | sh, rm -rf /, terraform destroy, kubectl delete namespace, docker system prune -a, DROP TABLE |
Reads of protected files (cat, grep, ls, head) stay allowed. Unknown verbs fail closed, so tools nobody predicted are covered by construction.
Every block appends one JSON line to .claude/guardrails-audit.jsonl. python3 gates/report.py [log] [YYYY-MM] turns a month of it into a Markdown report: blocks by reason, by tool, by day, most recent ten.
The first version was a deny-list (rm, mv, rmdir). It was defeated in minutes by every row in the first table above, and two of the blocks it did produce came from a quoting parse error rather than detection. A guard that is wrong in both directions cannot be reasoned about. The rule is now inverted and every bypass is a permanent regression test.
Three more lessons, all found by attacking rather than reading:
realpath, against the session cwd Claude Code sends) before comparing. Otherwise ../, symlinks and a cd mid-command change the answer.config.json, a payload with no path../install.sh /path/to/your/repo .env secrets infra/prod
This copies hooks/, gates/ and a rewritten config.json into your-repo/guardrails/, protects the paths you list (relative to the repo root), and prints the .claude/settings.json block to add. Then:
cd /path/to/your/repo && guardrails/tests/smoke.sh
Eight assertions against the installed policy. Wire the same command into CI and the protection is proven on every push, not just on the day it was installed.
config.json all policy: protected paths, read-only verbs, deny patterns, audit log path
hooks/ shell wrappers Claude Code calls (pre_bash_guard.sh, pre_write_guard.sh, stop_gate.sh)
gates/ the logic: bash_guard.py, write_guard.py, check_deliverable.py, report.py, _lib.py
tests/run_tests.sh the lab suite (104 assertions); tests/smoke.sh for installed copies
.claude/settings.json the two PreToolUse hooks
stop_gate.sh is an optional Stop hook that refuses to end a session until a named deliverable exists and has real content. It is tested but not wired by default.
Exit 0 allows, 2 denies, 1 fails (Stop gates), anything else means "the gate is broken", which the hook layer treats differently from "the gate said no". Exactly one JSON line on stdout; human text on stderr. gates/_lib.py holds the whole contract.
Hooks run inside Claude Code. They do nothing for a token that is already in the agent's environment, for other tools that read the same files, or for an agent with a different permission model (Cursor has its own). Treat this as one layer: scoped credentials and an OS sandbox are the others.
Paste your settings.json and CLAUDE.md into the grader. It runs in the browser, uploads nothing, and names the gaps.
MIT licence. Daniel Meshulam, Israel. I install and test-prove this for teams on Claude Code and Cursor: agent-guardrails.meshulam791.workers.dev.
3 commits
Shell
60.9%
Python
39.1%
Fail-closed guardrails for Claude Code (and any agent that runs shell commands and edits files through hooks). Two PreToolUse hooks, one policy file, an audit log, a monthly report, and a test suite that proves every block.
./tests/run_tests.sh # 104 assertions, all green
Built after an evening of breaking my own deny-list. The story is in the write-up, the short version is below.
| Layer | Rule | Examples that are denied |
|---|---|---|
| Protected paths | A command that references a protected path is denied unless every verb in it is on a short read-only allow-list | rm, mv, cp, tee, sed -i, dd, rsync, find -delete, sh -c "rm ...", P=protected; rm $P/x, (cd protected && rm x), echo x >| protected/x, symlink and ../ dodges |
| File writes | Write, Edit, MultiEdit and NotebookEdit are denied inside protected paths and outside the allowed tree; every path key in the payload is inspected, including nested edits[] and notebook_path | writes into protected/, writes to /tmp, payloads with no readable path (fails closed) |
| Destructive patterns | Regexes on the raw command, checked before any path logic | git push --force, git reset --hard, git clean -f, git checkout -- ., git branch -D, curl | sh, rm -rf /, terraform destroy, kubectl delete namespace, docker system prune -a, DROP TABLE |
Reads of protected files (cat, grep, ls, head) stay allowed. Unknown verbs fail closed, so tools nobody predicted are covered by construction.
Every block appends one JSON line to .claude/guardrails-audit.jsonl. python3 gates/report.py [log] [YYYY-MM] turns a month of it into a Markdown report: blocks by reason, by tool, by day, most recent ten.
The first version was a deny-list (rm, mv, rmdir). It was defeated in minutes by every row in the first table above, and two of the blocks it did produce came from a quoting parse error rather than detection. A guard that is wrong in both directions cannot be reasoned about. The rule is now inverted and every bypass is a permanent regression test.
Three more lessons, all found by attacking rather than reading:
realpath, against the session cwd Claude Code sends) before comparing. Otherwise ../, symlinks and a cd mid-command change the answer.config.json, a payload with no path../install.sh /path/to/your/repo .env secrets infra/prod
This copies hooks/, gates/ and a rewritten config.json into your-repo/guardrails/, protects the paths you list (relative to the repo root), and prints the .claude/settings.json block to add. Then:
cd /path/to/your/repo && guardrails/tests/smoke.sh
Eight assertions against the installed policy. Wire the same command into CI and the protection is proven on every push, not just on the day it was installed.
config.json all policy: protected paths, read-only verbs, deny patterns, audit log path
hooks/ shell wrappers Claude Code calls (pre_bash_guard.sh, pre_write_guard.sh, stop_gate.sh)
gates/ the logic: bash_guard.py, write_guard.py, check_deliverable.py, report.py, _lib.py
tests/run_tests.sh the lab suite (104 assertions); tests/smoke.sh for installed copies
.claude/settings.json the two PreToolUse hooks
stop_gate.sh is an optional Stop hook that refuses to end a session until a named deliverable exists and has real content. It is tested but not wired by default.
Exit 0 allows, 2 denies, 1 fails (Stop gates), anything else means "the gate is broken", which the hook layer treats differently from "the gate said no". Exactly one JSON line on stdout; human text on stderr. gates/_lib.py holds the whole contract.
Hooks run inside Claude Code. They do nothing for a token that is already in the agent's environment, for other tools that read the same files, or for an agent with a different permission model (Cursor has its own). Treat this as one layer: scoped credentials and an OS sandbox are the others.
Paste your settings.json and CLAUDE.md into the grader. It runs in the browser, uploads nothing, and names the gaps.
MIT licence. Daniel Meshulam, Israel. I install and test-prove this for teams on Claude Code and Cursor: agent-guardrails.meshulam791.workers.dev.
3 commits
Shell
60.9%
Python
39.1%