第 25 章 | PreToolUse hook——动态校验

更新于 2026/5/12
💡 进群学习加 wx: 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 用在另一个方向——通知。