作者通过隔离实验环境实测发现,Claude Code的PreToolUse钩子在子Agent调用时依然阻塞而非仅返回updatedInput,实锤了这个持续开放的issue。
Claude Code 是 Anthropic 的终端编程 Agent:它在你机器上运行 shell 命令并编辑文件。它允许你安装 hooks——一种在工具运行前调用的小脚本,可以重写命令或拒绝执行。这是阻止它执行删除操作的常用方式。
关于这个机制有一个持续流传的说法:用户设置中的 PreToolUse hook 不会对 subagent(主 Agent 派来完成某项工作的子 Agent)内部的 Bash 调用触发。如果这是真的,你安装的每个安全 hook 都会存在漏洞——你在主线程阻止了 rm -rf,模型把工作交给子 Agent,而子 Agent 却会毫无防备地执行它。相关讨论见 #34692,在此之前是 #21460,#88441 仍处于开放状态。
今年三月,我在 #34692 下添加了"可以确认"。没有步骤、没有输出、没有版本号。后续有更多人表示认同,讨论串至今仍活跃。
已经有人做过两次测量了。四月时,rwilk002 在 2.1.119 / Windows 11 / Git Bash 环境下用 JSON 记录 hook 报告验证为假。今年八月,一位 Anthropic 维护者在 2.1.233 / macOS 上得到相同结果。我等了五个月才去验证。下面是第三个数据点,在 Linux 上,且不仅测试了"hook 是否被调用",还覆盖了 deny 和 updatedInput 的情况。
我不是工程师。Claude Code 负责实现和调查;我的工作是指导它,并检查返回的结果。这只是其中一次检查。
不要用你真实的配置来做这个。把它隔离开来,一次性搭建好整个测试工具:
mkdir -p /tmp/h/cfg /tmp/h/proj && chmod 700 /tmp/h/cfg
cp ~/.claude/.credentials.json /tmp/h/cfg/
有一个坑花了我不少时间:全新的 CLAUDE_CONFIG_DIR 没有凭证,所以运行会在"Not logged in"处停止,根本无法进行任何测量。这就是上面要复制凭证的原因。如果你不想复制凭证,可以用 --settings <file> 代替——它只切换设置文件,保留你真实的认证信息。
这个 hook 记录重要的字段,并对两个标记做出反应。MARKER_DENY 拒绝调用。MARKER_REWRITE 重写它。我两个都要,因为"hook 是否被调用"和"hook 是否真的阻止了什么"是两个不同的问题,而后者才是安全 hook 的意义所在。
cat > /tmp/h/hook.py <<'EOF'
import sys, json
d = json.load(sys.stdin)
cmd = (d.get("tool_input") or {}).get("command", "")
open("/tmp/h/hook.jsonl", "a").write(json.dumps(
{"agent": d.get("agent_type") or "TOPLEVEL",
"agent_id": d.get("agent_id"), "cmd": cmd}) + "\n")
if "MARKER_DENY" in cmd:
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse", "permissionDecision": "deny",
"permissionDecisionReason": "test guard: this command is blocked"}}))
elif "MARKER_REWRITE" in cmd:
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": {"command": cmd.replace("MARKER_REWRITE", "REWRITTEN")}}}))
EOF
设置文件放在隔离的配置目录中。权限块很关键:claude -p 是非交互式的,所以没有它,运行会在批准环节卡住,在任何 hook 触发之前就失败了,看起来就像是"hook 没有运行"。
cat > /tmp/h/cfg/settings.json <<'EOF'
{"permissions": {"allow": ["Bash", "Task"], "defaultMode": "acceptEdits"},
"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [
{"type": "command", "command": "python3 /tmp/h/hook.py"}]}]}}
EOF
然后一次运行四个用例:父级自己运行每个标记,同时父级生成一个 subagent 来运行每个标记。
cd /tmp/h/proj && CLAUDE_CONFIG_DIR=/tmp/h/cfg claude -p \
"1. Run 'echo MARKER_REWRITE_TOP' with Bash yourself. \
2. Run 'echo MARKER_DENY_TOP' with Bash yourself. \
3. Spawn a general-purpose Task subagent that runs 'echo MARKER_REWRITE_SUB'. \
4. Spawn one that runs 'echo MARKER_DENY_SUB'. \
Report the exact output or error of each." \
--output-format stream-json --verbose
四个全部通过。hook 不仅在 subagent 中被调用了——deny 阻止了调用,updatedInput 重写了它,完全和主线程上的行为一样。
在 2.1.233 上测量,然后在 2.1.246 上重新运行,得到相同的四个结果。
这部分是文档中记录的行为——hooks 参考文档说当 hook 在 subagent 内部触发时,agent_id 和 agent_type 会被填充——这里验证了这个行为:
{"agent":"TOPLEVEL", "agent_id":null, "cmd":"echo MARKER_REWRITE_TOP"}
{"agent":"general-purpose", "agent_id":"aa23eb22fd31c7276", "cmd":"echo MARKER_REWRITE_SUB"}
agent_type(在上面记录为 agent)和 agent_id 只在子级中存在。每次运行时 id 都会变化,所以要根据是否存在来匹配,而不是根据值。session_id 和 cwd 与父级相同,所以这两个字段无法区分它们。如果你想让 hook 在 subagent 内部表现不同——比如说,对无人值守工作应用更严格的规则——这些就是关键的区分字段。
我在自己的机器上测量了这些内容——Linux (WSL2)、通过 claude -p 调用 CLI、从隔离的 CLAUDE_CONFIG_DIR 使用用户级 hook、测试了两个版本、使用了 general-purpose Task subagent。结果没有复现。这就是完整的结论。
报告相反结果的人不一定错了。旧版本、插件提供的 hook、项目级设置、不同的匹配器以及其他 subagent 类型都构成了不同的测试环境,我没有测试过这些。
值得注意的是,现有的一个报告 #21460 以退出码 1 阻塞,而文档说退出码 2 才是阻塞信号。我不认为这完全解释了他们的结果——他们报告父级被阻塞,只有子级通过——但这是我首先要重新检查的地方。
我实际上想说的是:不要相信这个,也不要争论它。在你自己的环境中运行上面的工具一次。如果它在你这里复现了,日志文件是最快的方式来展示它,因为它记录的是实际到达的内容,而不是我们任何一方的推测。
写"可以确认"不花什么力气,但它仍然被算作证据。"我看到相同的症状"和"相同的原因正在发生"是两个不同的说法,但在公开讨论串中,它们累积起来被视为同样的数字。
五个月后,这反过来影响了我,因为我发布了一套免费的安全 hook,它们的整个前提是覆盖模型运行的一切——这正是为什么我希望测量它而不是留给自己的乐观主义。我曾在路上放了一块石头,然后绊倒了自己。
Three rules I now hold myself to:
我现在坚持的三条规则:
只有在能写出步骤时才添加认同。 永远不要对症状匹配使用"已确认";只在原因匹配时才使用。 当版本改变时,重新测量安全假设。传统认知有保质期。
我在八月撤回了三月在 #34692 下的评论,并附上了测量结果。
这个 hook 集合是免费的,采用 MIT 许可:https://github.com/yurukusa/cc-safe-setup