Author: Harry Philippe Mbouyap. All the code below is MIT and lives in a small repo you can
clone and run: https://github.com/hbouyap/claude-code-safe-automation
Getting an AI agent to write code is a solved problem. The part that keeps teams up at night
is different: do you trust it to run commands on your machine, unattended?
One rm -rf in the wrong directory, one git push --force to the wrong branch, and "the agent
saved me an afternoon" becomes "the agent cost me a week." So most people keep the agent on a
tight leash — approving every step — which throws away most of the value.
There's a better middle ground. You can give an agent real autonomy on a workflow and still make
it structurally unable to do the few things that would hurt. Here's the three-layer approach I
use with Claude Code.
Layer 1 — Scoped permissions
Start by telling the agent what it's allowed to touch, in .claude/settings.json:
{
"permissions": {
"allow": ["Bash(ls:*)", "Bash(git status:*)", "Bash(git diff:*)", "Read(*)", "Grep(*)"],
"deny": ["Bash(sudo:*)", "Read(.env)", "Read(**/secrets/**)"]
}
}
This is necessary but not sufficient. Permission globs are coarse — Bash(git:*) allows both
git status and git push --force. For the commands that actually matter, you want logic, not
a glob. That's the next layer.
Layer 2 — A PreToolUse guardrail hook
Claude Code can run a hook before every tool call. If the hook exits with code 2, the call
is denied and the reason is handed back to the model. That's the perfect place to put a
deny-list of things that should never run unattended:
import json, re, sys
DANGEROUS = [
# rm -rf in any flag order, plus the PowerShell alias rm -Recurse -Force
(r"\brm\b(?=.*(?:-[a-z]*r|--recursive))(?=.*(?:-[a-z]*f|--force))", "recursive force delete"),
(r"\bremove-item\b(?=.*-rec)(?=.*-for)", "recursive force delete (Windows)"),
(r"\bgit\s+push\b(?=.*(?:--force\b|\s-f\b))", "force push can overwrite history"),
(r"\bgit\s+reset\s+--hard\b", "hard reset discards work"),
(r"\b(?:curl|wget)\b.*\|\s*(?:sudo\s+)?(?:sh|bash)\b", "pipe-to-shell from the network"),
# ... fork bombs, raw disk writes, sudo, format, shred, del /s, rmdir /s
]
def main():
event = json.load(sys.stdin)
if event.get("tool_name") != "Bash":
return 0
command = event.get("tool_input", {}).get("command", "")
for pattern, reason in DANGEROUS:
if re.search(pattern, command, re.IGNORECASE):
print(f"guard: blocked -- {reason}", file=sys.stderr)
return 2 # Claude Code treats exit 2 as "deny"
return 0
sys.exit(main())
Two details that matter more than they look:
-
Cover Windows too. Most guardrail snippets online only block
rm -rf. If your team runs Claude Code on Windows, you also needRemove-Item -Recurse -Force,del /s,rmdir /s,format, and friends — otherwise the guardrail is theater on half your machines. -
Match case-insensitively and across flag orders.
rm -rf,rm -R -f,rm --recursive --forceandrm -Rfare the same danger. The two-lookahead regex above catches "has a recursive flag AND has a force flag" regardless of arrangement, while still letting a plainrm file.txtthrough.
Wire it up in settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command", "command": "python .claude/hooks/guard.py" } ] }
]
}
}
Now test it — this is the part people skip:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | python .claude/hooks/guard.py
# -> exit 2, blocked
echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' | python .claude/hooks/guard.py
# -> exit 0, allowed
Deny-list vs allow-list
A deny-list is friendly for interactive work on your own machine: block the known-dangerous,
allow everything else. For unattended runs or client work, flip it to fail-closed: only
commands you explicitly allow run, everything else is refused. Log every decision either way, so
you have an audit trail of what the agent tried.
Layer 3 — Gate every tool you expose (MCP)
Shell isn't the only way an agent acts on your world. The moment you give it an MCP server that
talks to your API or database, the same discipline applies. The rule that keeps you safe:
Reads are free. Writes are gated.
Give the agent all the read access it needs, but make every state change require a second
deliberate signal — or refuse it outright. A read-only SQLite server, for example:
@mcp.tool()
def query(sql: str) -> str:
"""Run a SELECT query. Non-SELECT statements are refused."""
if not sql.lstrip().lower().startswith("select"):
return "refused: only SELECT statements are allowed (read-only guardrail)."
# ... open the DB with a read-only connection and run it
And for anything that mutates state, require an explicit confirm=true argument so the agent
can't change things by accident:
@mcp.tool()
def update_resource(path: str, body: str, confirm: bool = False) -> str:
if not confirm:
return "refused: pass confirm=true to perform this write (guardrail)."
# ... perform the write
Keep credentials in environment variables, never in code or tool arguments — that way they
can't leak into a transcript or a log.
Putting it together
Three layers, each doing one job:
- Scoped permissions — the coarse fence.
- A PreToolUse hook — the smart gate for shell commands, deny-list or fail-closed allow-list, with an audit log.
- Gated MCP tools — reads free, writes confirmed, secrets in the environment.
With those in place, you can point Claude Code at a real workflow — write, test, deploy, verify —
and let it run, because the handful of actions that could actually hurt are structurally blocked.
Grab the code
Everything above is in a small MIT repo you can clone and run in a minute:
https://github.com/hbouyap/claude-code-safe-automation — a working guardrail, scoped
permissions, a reviewer subagent, and a minimal MCP server template.
If you'd rather skip the assembly, I also package a fuller MCP & Guardrails Kit (fail-closed
allow-list mode, three MCP server templates, a subagent library, and a one-command installer) —
and I build these setups for teams directly. Links are on my profile.
What's your approach to giving agents autonomy safely? I'd like to hear it.
Top comments (0)