AI编码
dev.to ·
@aicoding-guide
用 Claude Code PreToolUse hook 阻止 rm -rf,并了解其局限
文章介绍如何用 PreToolUse hook 在工具执行前检查并拒绝危险命令,如 rm -rf。其价值在于触发位置:文档称 PreToolUse hook 在任何权限模式(包括 dontAsk)下先于权限模式检查触发,返回 permissionDecision: "deny" 即使在 bypassPermissions 模式或使用 --dangerously-skip-permissions 时也能阻止工具。文章涵盖两种阻止方式(退出码 2 和 permissionDecision)、为何切换权限模式无法绕过,以及 hook 仍无法做到的事和字符串匹配失效的场景。
AI推荐理由 文章介绍用 Claude Code 的 PreToolUse hook 阻止 rm -rf,说明该 hook 在任何权限模式(包括 dontAsk 和 bypassPermissions)下先于权限检查触发,返回 permissionDecision: deny 可阻止工具执行,并讨论字符串匹配的局限。对使用 Claude Code 的开发者有直接操作价值,但来源为个人博客转载,需自行验证文档细节。
68
正文
Originally published at https://aicoding-guide.com.
Some commands you never want running unreviewed, and rm -rf is the classic one. A PreToolUse hook lets a script inspect the call and refuse it before the tool runs.
What makes this hook worth using is its position in the chain: the documentation states that PreToolUse hooks fire before any permission-mode check, in every permission mode including dontAsk, and that a hook returning permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions.
Key point
What you will learn
Two ways to block: exit code 2 and permissionDecision
Why changing permission mode doesn't get around it, and what hooks still can't do
Where string matching breaks down, and what to pair it with
Blocking with exit code 2
The shortest form. Write a message to stderr and exit 2: the tool call is denied and that stderr is fed back to Claude.
#!/usr/bin/env bash
# .claude/hooks/block-rm-rf.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eq '(^|[;&|[:space:]])rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)'; then
echo "Blocked: recursive force-remove is not allowed here. Name the paths individually." >&2
exit 2
fi
exit 0
Enter fullscreen mode
Exit fullscreen mode
On macOS and Linux the script has to be executable:
chmod +x .claude/hooks/block-rm-rf.sh
Enter fullscreen mode
Exit fullscreen mode
Register it in .claude/settings.json. As in the documentation's example, you can run a logging hook alongside the guardrail:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r .tool_input.command >> ~/.claude/bash.log"
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh"
}
]
}
]
}
}
Enter fullscreen mode
Exit fullscreen mode
The documentation walks through what happens: both hooks execute in parallel, the logging hook exits 0 and reports no decision, and the guardrail exits 2 to deny the call. The deny takes precedence, so the command is blocked — and the log entry is still written, because the logging hook already ran.
Blocking with permissionDecision
Instead of an exit code, print JSON to stdout. This lets you pass a structured reason.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Recursive deletes are blocked here. Run git clean -n first, then remove paths individually."
}
}
Enter fullscreen mode
Exit fullscreen mode
PreToolUse accepts three values:
Value
Effect
allow
Skip the interactive permission prompt. Deny and ask rules still apply
deny
Cancel the tool call and send the reason to Claude
ask
Show the permission prompt as normal
With deny, permissionDecisionReason is fed back to Claude, so writing why and what to do instead keeps the work moving rather than just stopping it.
allow cannot override a deny rule
The reverse does not hold. A hook returning allow does not bypass deny rules from settings, and it cannot suppress prompts for MCP tools marked requiresUserInteraction or for connector tools your organization set to ask. As the documentation puts it: hooks can tighten restrictions but not loosen them past what permission rules allow.
Cover file edits too
rm arrives through the Bash tool, but an Edit or Write that clobbers an important file is the same class of accident. The documentation shows a path-based guard:
#!/bin/bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0
Enter fullscreen mode
Exit fullscreen mode
Register that one with a matcher of Edit|Write. For the shape of the JSON these scripts read, see The JSON your Claude Code hooks receive on stdin; for matcher values, see Every value you can put in a Claude Code hook matcher.
Where string matching breaks
This is the part to be honest about. Matching the command text is easy to slip past.
Written as
Matched by a naive rm -rf check
rm -rf build
Yes
rm -r -f build
No
rm --recursive --force build
No
FLAGS="-rf"; rm $FLAGS build
No
find . -delete
Not an rm at all
The script above handles -rf and -fr orderings, and that is still not exhaustive. A hook is an effective guardrail against mistakes; it is not a mechanism for stopping deliberate evasion.
For anything you need genuinely enforced, layer these underneath:
Permission deny rules: the documentation states that hook decisions don't bypass permission rules — Claude Code evaluates deny and ask rules regardless of what a PreToolUse hook returns. See Claude Code permissions in settings.json
The sandbox: OS-level restriction that also covers files a script opens indirectly
Worth knowing too: rm and rmdir targeting a critical path are never auto-approved in any mode, and no allow rule or PreToolUse hook allow approves them.
Glossary
PreToolUse: the event that fires immediately before a tool runs. Denying here means the tool call never happens.
Checking it works
After registering, run /hooks and confirm the hook appears under the right event. To test the script by itself, pipe sample JSON into it:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x"}}' | ./.claude/hooks/block-rm-rf.sh; echo "exit=$?"
Enter fullscreen mode
Exit fullscreen mode
exit=2 is what you want to see.
Summary
PreToolUse hooks fire before permission-mode checks, so a deny holds even in bypassPermissions
Two ways to block: exit 2 for brevity, permissionDecision JSON to pass a reason
Multiple hooks on one event run in parallel, and a deny wins
A hook's allow cannot override a deny rule — hooks tighten, they don't loosen
String matching misses rm -r -f and variable expansion; use deny rules and the sandbox for real enforcement