用 PreToolUse 钩子把 agent 越权的那一步拦下来,顺带留一份能查的日志
提示词里写”不要删文件""不要访问外网”,这两句没有强制力。模型的目标是完成你给的任务,当限制挡在任务前面,它会自己找路。近几个月公开的事故复盘里,模式都一样:agent 绕过应用层的安全控制,去完成它被指派的任务。
能拦住它的地方在工具调用之前。Claude Code 的 PreToolUse 钩子就是这个位置——它在每次工具调用执行前触发,而且只有这个位置能真正拦下来。
装什么
两件事:一个拒绝危险命令的判断,一条无条件落盘的审计记录。前者防出事,后者保证出事之后你能回答”它到底做了什么”。
配置写在 .claude/settings.json(项目级,可提交)或 ~/.claude/settings.json(用户级):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"
}
]
},
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "powershell.exe",
"args": ["-NoProfile", "-File", "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.ps1"]
}
]
}
]
}
}
matcher 匹配的是 tool_name。只含字母数字下划线连字符时是精确匹配,Bash|PowerShell 表示两个都要;含其他字符时按 JavaScript 正则处理,比如 mcp__memory__.* 匹配某个 MCP 服务器的全部工具。
${CLAUDE_PROJECT_DIR} 指向项目根。用 args 数组的形式是 exec 调用,不走 shell,能避开一堆引用问题。
macOS / Linux 脚本
.claude/hooks/guard.sh,记得 chmod +x:
#!/bin/bash
set -u
INPUT="$(cat)"
LOG="$HOME/.claude/audit/$(date +%Y-%m-%d).jsonl"
mkdir -p "$(dirname "$LOG")"
# 1) 无条件留痕:整条工具调用原样追加
printf '%s\n' "$INPUT" >> "$LOG"
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')"
[ -z "$CMD" ] && exit 0
BLOCK_RULES=(
'rm -rf /'
'rm -rf ~'
':(){'
'mkfs'
'chmod -R 777 /'
'curl .*| *(ba)?sh'
'wget .*| *(ba)?sh'
'git push .*--force.*(main|master)'
)
for RULE in "${BLOCK_RULES[@]}"; do
if printf '%s' "$CMD" | grep -Eq -- "$RULE"; then
echo "Blocked by guard: matched '$RULE'" >&2
exit 2
fi
done
exit 0
依赖 jq。Windows 上没有就用下面那份。
Windows 脚本
.claude/hooks/guard.ps1:
$ErrorActionPreference = 'Stop'
$input = [Console]::In.ReadToEnd()
$logDir = Join-Path $env:USERPROFILE '.claude\audit'
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
Add-Content -Path (Join-Path $logDir ("{0}.jsonl" -f (Get-Date -Format 'yyyy-MM-dd'))) -Value $input
$json = $input | ConvertFrom-Json
$cmd = if ($json.tool_input -and $json.tool_input.command) { $json.tool_input.command } else { '' }
$rules = @(
'Remove-Item\s+.*-Recurse.*(C:\\|\\$|~)',
'Format-Volume',
'Invoke-Expression.*\(Invoke-WebRequest',
'iwr\s+.*\|\s*iex',
'git\s+push\s+.*--force.*(main|master)'
)
foreach ($r in $rules) {
if ($cmd -match $r) {
[Console]::Error.WriteLine("Blocked by guard: matched '$r'")
exit 2
}
}
exit 0
退出码这件事必须搞清楚
这一条写错,前面全白干:
exit 2= 阻塞工具调用。不管你 stdout 打了什么,exit 2 一定会拦下来,连 JSON 里写"allow"也覆盖不了。阻塞原因取自 stderr。exit 0且不打 JSON = 没有决定,工具调用继续走正常权限流程。沉默不等于批准,只是不拒绝。exit 1不会阻塞。它会被当成非阻塞错误,动作照常继续。Unix 那套”1 表示失败”在这里不适用。- 想用 JSON 精细控制(放行、询问、改写参数),stdout 输出
hookSpecificOutput.hookEventName = "PreToolUse"配permissionDecision为deny/ask/allow,还可以用updatedInput改写工具参数。 - 钩子超时不阻塞。默认超时 600 秒,超时后工具调用照常执行。别把”跑得慢的钩子”当闸门用,要强制就 exit 2。
怎么知道自己做对了
跑通一次不算。下面 5 条全过才算:
- 危险命令必须被拦。 让 agent 执行
rm -rf /tmp/anything,它应该被拒,并且把 stderr 里那句Blocked by guard显示出来。没有被拒,说明脚本根本没跑——先检查matcher和脚本的+x权限。 - 正常命令必须放行。
npm test、git status这类要照常执行。如果它们也被拦,说明你的规则写宽了,agent 会开始绕,反而更糟。 - exit 1 的对照组要真的不拦。 临时把脚本里的
exit 2改成exit 1跑一次,同一个危险命令应该继续执行。这是验证你理解对了退出码语义的唯一办法。 - 日志里有完整的入参。 跑几轮之后打开
~/.claude/audit/2026-09-29.jsonl,每一行应该是一个完整的工具调用 JSON,含tool_name、tool_input、session_id、cwd。只有拒绝记录没有全量记录,等于只记了”它没干坏事”,出事那天你查不到东西。 - 换个目录仍然生效。 在子目录里启动 Claude Code 再试一次。钩子在会话起始目录、项目根、家目录或临时目录里都可能被解析,路径写死相对路径会在这里翻车——用
${CLAUDE_PROJECT_DIR}就是为了避开这个。
几个坑
- 钩子只管工具调用,管不了 agent 想什么。 它能挡住
rm -rf /,挡不住 agent 换个写法达到同样效果。这套东西的价值是”把明显的越界挡在门外”,不是完整的沙箱。 - 规则别写太长。 一张 50 条正则的黑名单,agent 会花掉大量上下文去试探边界。10 条以内,只留真正不可逆的那些。
- 别在钩子里做网络请求。 延迟会叠加到每一次工具调用上,超时还不阻塞,等于给自己造了个假的安全感。
- stdout 只能是 JSON 或什么都不出。 shell profile 里那些 echo 会污染输出,让 JSON 解析失败,钩子退化成非阻塞错误。
- 审计日志会记下密钥。 工具调用的输入里可能有 token、连接串。日志目录的权限自己看好,别同步到公开仓库。
顺带一句
一个人干活最容易犯的错,是把”我提醒过它了”当成”我限制住它了”。这两件事之间的距离,今年已经被好几家大厂用真实事故量出来了。
钩子不解决所有问题,但它把这条线从”靠模型的自觉”挪到了”靠进程的边界”。这一步不需要买硬件,20 分钟能装完,装完立刻能验收。