作者系统梳理了Claude Code PreToolUse钩子不生效的排查路径,包括settings.json路径优先级、hooks配置位置、日志输出机制等实际避坑点。
你写了一个 PreToolUse hook,测试时它拒绝了它应该拒绝的操作。然后你把它接入 settings.json,运行 agent,却眼睁睁看着它写入了一个本应被拒绝的文件。
接下来怎么办?有两种可能,但从你的视角看它们完全一样:要么 hook 运行了然后决定放行,要么 hook 根本没运行。transcript 中没有任何东西能区分它们,因为一个选择放行的 hook 什么都不说,而在 tool 事件上,hook 在 exit 0 时的 stdout 会进入 debug log 而不是返回给你。沉默是成功的样子,沉默也是所有失败的样子。
这就是前一篇的结尾留下的缺口。测试你的 hook 能证明 hook 本身是正确的,但它无法说明 Claude Code 是否调用了它。
首先,三个零成本的检查
在写任何代码之前,先排除那些无聊的答案。按出镜频率从高到低排列:
读取的 settings 文件不是你以为的那个。Hook 位于 settings.json 的 hooks 下面,而这类文件有好几个:项目里的 .claude/settings.json、旁边的 .claude/settings.local.json,还有你个人的 ~/.claude/settings.json。改错了文件会产生一个完全合法、完全不生效的配置。先确认你实际打开的是哪个文件,而不是你打算改的那个。
配置是在 session 运行中编辑的。Hook 配置在 session 启动时被捕获。你在十分钟前添加的 hook,如果 session 是一小时前启动的,那它根本没有在运行——这个尤其有迷惑性,因为磁盘上的文件是对的,你一眼就能看到它。
matcher 没有匹配上。matcher 按 tool 名称过滤,它必须与 tool 被实际调用的名称匹配。Write|Edit 永远不会为一个由 Bash 创建的文件触发,因为对 matcher 来说 Bash 是一个 shell,它用重定向做什么是它自己的事。如果你想知道什么在触发,claude --debug 会实时打印 hook 活动。
如果以上都不是问题,就别猜了,让 hook 自己记录发生了什么。
一个记录每次调用的包装器
思路很简单:不要改动你的 hook,在它外面包一层。在前面放一个进程,向文件追加一行,然后用相同的字节通过 stdin 运行真正的 hook,同时原封不动地转发它的 stdout、stderr 和退出码。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{ "type": "command", "command": "node .claude/trace-hook.mjs node .claude/guard.mjs" }
]
}
]
}
}
包装器本身:
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { appendFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
const TRACE = process.env.HOOK_TRACE ?? resolve(process.cwd(), ".claude/hook-trace.jsonl");
function record(entry) {
try {
mkdirSync(dirname(TRACE), { recursive: true });
appendFileSync(TRACE, JSON.stringify({ at: new Date().toISOString(), ...entry }) + "\n");
} catch {
// Tracing must never change the outcome of the hook it is watching.
}
}
async function readStdin(stream = process.stdin) {
const chunks = [];
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks);
}
const [command, ...args] = process.argv.slice(2);
const raw = await readStdin();
let payload = null;
try {
payload = JSON.parse(raw.toString("utf8"));
} catch {
// A payload we cannot parse is still an invocation worth recording.
}
record({
phase: "invoked",
event: payload?.hook_event_name ?? null,
tool: payload?.tool_name ?? null,
file: payload?.tool_input?.file_path ?? null,
bytes: raw.length,
});
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
let out = "", err = "";
child.stdout.on("data", (d) => { out += d; process.stdout.write(d); });
child.stderr.on("data", (d) => { err += d; process.stderr.write(d); });
child.on("error", (e) => {
record({ phase: "spawn-failed", error: e.message });
process.exit(1);
});
child.stdin.on("error", () => {});
child.stdin.end(raw);
child.on("close", (code) => {
let decision = null;
try {
decision = JSON.parse(out)?.hookSpecificOutput?.permissionDecision ?? null;
} catch {
decision = out.trim() === "" ? null : "unparseable";
}
record({
phase: "returned",
exit: code,
decision,
blocked: code === 2 || decision === "deny",
stderr: err.trim().slice(0, 2000) || null,
});
process.exit(code ?? 1);
});
有三处细节是承力的,而这三点恰恰都是粗心的包装器会悄悄改变它本应去发现的结果的地方。
process.stdout.write(d) 逐字转发子进程的字节,而不是在解析后重新打印一份。Hook 协议是"stdout 上的内容按 JSON 解析"——重新序列化意味着 Claude Code 读到的是包装器对 hook 输出的看法,而不是输出本身。
process.exit(code ?? 1) 保留了子进程的退出码,而 exit 2 就是全部的关键:它是唯一会阻止的退出状态。一个以 process.exit(0) 结束的包装器会把每一次 block 变成 allow,而这正是它存在所要检测的那个失败。
child.stdin.end(raw) 发送原始字节,而不是重新编码后的对象。无论 payload 包含什么——你不认识的字段、空格、unicode——都原封不动地到达真正的 hook。
现在 .claude/hook-trace.jsonl 直接回答了这个问题。有四种形状,每种指向不同方向:
没有文件,或没有你要调查的那次调用的行。Hook 从未被调用。你的 hook 里没有任何问题,问题在上游——matcher、settings 文件,或者 config 产生前的 session。
有 invoked 行但没有 returned 行。Hook 被调用了但没有结束。挂起、超时,或者进程被从下面杀掉了。超时是非阻塞错误,所以调用继续执行了。
两条行都有,blocked: false。Hook 运行了,看到了调用,但没有意见。现在你的 hook 确实是问题所在,而前一篇的测试工具箱是正确的——把那个确切 payload 喂给它看它怎么说。
两条行都有,blocked: true,但写入仍然发生了。很少见,但值得看两遍,因为它通常意味着你看到的那个写入来自 hook 从未见过的地方——shell 重定向、子 agent、构建步骤——而你在追错误的 tool 调用。
file 字段让最后一种情况可被找到。在 trace 中 grep 那个被修改的路径。如果不在那里,说明没有任何 hook 曾经有机会拒绝它。
诊断工具本身有 bug
我给这个包装器写了一个测试工具箱——八个场景:一个拒绝的 hook、一个沉默的 hook、一个崩溃的 hook、一个 exit 2 的、一个把 stdin 回显回去以便比较字节的、一个不存在的 hook、一个垃圾 payload,还有一个什么都不运行的情况。二十一条断言。二十条通过了。
失败的是这一行,原始形式:
stderr: err.trim().slice(0, 200) || null,
崩溃 hook 场景断言记录的 stderr 包含错误信息。实际上没有。当 Node 因未捕获异常退出时,它先打印错误文件的绝对 URL,然后是代码帧,然后是脱字符,最后才是 Error: boom。在我的测试中,那条横幅在消息开始前有 251 个字符宽,所以 200 字符的限制存了文件路径而扔掉了唯一有用的部分。Trace 行看起来没问题——有 stderr 字段、有内容、是真实的——但恰恰没用,而这恰恰是你安装追踪器要去调查的那种情况。
这是一个小 bug,但有一般性的形状。诊断工具失败的方式和 guard 一样:静悄悄地,记录了一个看起来像答案的东西。两篇文章前是一个允许所有写入的 guard;上一篇是一个在执行一行之前就死掉的 hook,测试却通过了;这次撒谎的是那个仪器。把你知道答案的 case 跑上去,否则你就是在信任一个从未被质疑过的证人。
它仍然看不到的地方
包装器继承了它所包装的 hook 的每一个盲点。它只记录 matcher 匹配到的调用,所以它无法告诉你被 Bash、子 agent 或你的 agent 触发的构建步骤写入的文件。空的 trace 意思是"没有匹配的 tool 调用触及这里",而不是"什么都没有触及这里"——把第一个当成第二个,就是你怎么会在仅凭一个你误读的文件的情况下得出你的 agent 表现良好的结论。
这就是为什么事后检查仍然必要。在运行结束时运行 git status diff 看的是工作区实际的样子,不管是哪个工具到了那里。Hook 拒绝它能看到的东西;diff 抓住它看不到的东西。Trace 告诉你依赖的是两者中的哪一个——在那之前,这是一个你一直在靠假设回答的问题。
Agent Guardrails Kit 是这个代码的免费、组装好的版本——相同的模块,连在一起,带测试。