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.
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.
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 = [
(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"),
]
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:
rm -rf
. If your team runs
Claude Code on Windows, you also need Remove-Item -Recurse -Force
, del /s
, rmdir /s
,
format
, and friends — otherwise the guardrail is theater on half your machines.rm -rf
, rm -R -f
,
rm --recursive --force
and rm -Rf
are 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 plain rm file.txt
through.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
echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' | python .claude/hooks/guard.py
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.
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)."
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)."
Keep credentials in environment variables, never in code or tool arguments — that way they
can't leak into a transcript or a log.
Three layers, each doing one job:
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.
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
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.