第 25 章 | PreToolUse hook——动态校验
💡 进群学习加 wx: agentupdate
(申请发送: agentupdate)
(申请发送: agentupdate)
第 25 章:PreToolUse hook——动态校验
学习目标
写出能拦截"看似合法实则危险"的 bash 命令的脚本。
Hook 是什么
sequenceDiagram
participant CC as Claude Code
participant Hook as PreToolUse hook
participant Tool as 真正的 Bash
CC->>Hook: stdin: tool 调用 JSON
Hook->>Hook: 解析、判断
alt 通过
Hook-->>CC: exit 0
CC->>Tool: 真正执行
else 拦截
Hook-->>CC: exit 2 + stderr
CC->>CC: 显示拒绝原因,不执行
end→ Hook 是外部脚本,在 tool 调用前被 Claude Code 触发。
注册方式
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-bash.py"
}
]
}
]
}
matcher = 工具名(Bash / Edit / Write / ...)。
入参 JSON 长这样
{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/foo",
"description": "..."
},
"session_id": "...",
"transcript_path": "..."
}
退出码控制
| exit | 含义 |
|---|---|
0 |
通过,继续 |
2 |
拦截,stderr 内容显示给用户 |
| 其他 | 错误,但通常视为通过(不阻塞主流程) |
实例:guard-bash.py 完整解读
#!/usr/bin/env python3
import json, os, re, shlex, sys
from pathlib import Path
PROJECT_ROOT = Path(
os.environ.get("CLAUDE_PROJECT_DIR", "/Users/amanda/work/openspec")
).resolve()
DESTRUCTIVE = {"rm", "mv", "cp"}
SHELL_WRAPPERS = {"bash", "sh", "zsh", "dash"}
def is_inside_project(path_str):
if path_str.startswith("~"):
target = Path(path_str).expanduser()
elif path_str.startswith("/"):
target = Path(path_str)
else:
target = PROJECT_ROOT / path_str
try:
target.resolve().relative_to(PROJECT_ROOT)
return True
except ValueError:
return False
def check_command(cmd):
"""Recursively validate a shell command string."""
segments = re.split(r"\s*(?:;|&&|\|\||\|)\s*", cmd)
for seg in segments:
seg = seg.strip()
try:
tokens = shlex.split(seg)
except ValueError:
continue
if not tokens:
continue
head = tokens[0]
if head == "sudo" and len(tokens) > 1:
head, tokens = tokens[1], tokens[1:]
# 递归处理 bash -c "inner"
if head in SHELL_WRAPPERS and len(tokens) >= 3 and tokens[1] == "-c":
check_command(tokens[2])
continue
if head not in DESTRUCTIVE:
continue
for arg in tokens[1:]:
if arg.startswith("-"):
continue
if not is_inside_project(arg):
print(f"BLOCKED: `{head}` targets '{arg}' outside project ({PROJECT_ROOT})",
file=sys.stderr)
sys.exit(2)
data = json.load(sys.stdin)
if data.get("tool_name") != "Bash":
sys.exit(0)
cmd = data.get("tool_input", {}).get("command", "")
if cmd:
check_command(cmd)
sys.exit(0)
关键技巧
1. shlex.split 而不是 str.split
→ 正确处理引号、空格、转义
2. 命令分隔符正则: ; && || |
→ 复合命令逐段校验
3. bash -c "inner" 递归
→ 防止间接调用绕过
4. resolve() 然后 relative_to(PROJECT_ROOT)
→ 项目外路径会抛 ValueError → 拒
测试 hook
# 应该通过
echo '{"tool_name":"Bash","tool_input":{"command":"rm src/foo.py"}}' \
| ./.claude/hooks/guard-bash.py
echo "exit=$?" # 0
# 应该拦截
echo '{"tool_name":"Bash","tool_input":{"command":"rm /tmp/foo"}}' \
| ./.claude/hooks/guard-bash.py
echo "exit=$?" # 2
→ 上线前必须本地测。
Hook 兜不住的情况
✗ eval "$DANGEROUS_VAR" 变量内容 hook 看不到
✗ python -c "os.remove('/etc/x')" 非 shell 路径
✗ xargs rm < some-list 间接调用
✗ ln -s 软链接逃逸 链接指向项目外但绝对路径在项目内
→ 这些靠 deny 列表补(禁 eval / xargs / 等),或者上 Docker。
反模式
❌ hook 推理过头("看着像危险就拦")
→ 误伤合法命令
❌ hook 用复杂正则解析 shell
→ 永远会有 edge case 漏,请用 shlex
❌ hook 出 exit 1 / 3
→ Claude Code 行为可能未定义,请只用 0 或 2
❌ hook 自己写文件 / 改状态
→ hook 应是纯函数,不带副作用
❌ hook 跑得慢(>1s)
→ 每次 Bash 都被拖慢
你现在能做什么
- 写一个能感知项目根的 hook
- 测试 hook 的"该过的过、该拦的拦"
- 知道 hook 兜不住什么、怎么补
下一章把 hook 用在另一个方向——通知。