权限拒绝以后,别让 agent 原地重试:用 PermissionDenied 给出可执行改道

· 约 4 分钟阅读

自动模式里最烦的一种停顿,不是 agent 做错了,而是它请求了一个不被允许的工具调用。模型看见“权限拒绝”后,常会把同一条命令换个引号、换个路径再试一次;如果拒绝理由没有告诉它替代路径,这几轮尝试只会烧掉上下文和时间。

Claude Code 把这件事单独放进 PermissionDenied 事件:自动模式拒绝工具调用时会触发,包括没有得到分类器判定的拒绝。[1] 它与 PreToolUse 不同:后者发生在调用执行前并且能阻断;PermissionDenied 面对的是已经发生的拒绝。[1] 所以别把它当第二道权限墙。它更适合做失败后的路由器:把“不能做”翻译成“接下来可以怎么做”。

先分清:哪些拒绝值得救#

我会把拒绝分成三类。第一类是安全边界,例如生产删除、密钥文件和未知网络目标。这种情况不该让 agent 自动找绕路;给出原因后停住,让人决定。第二类是环境缺口,例如沙箱没开网络、命令不在白名单、后台子代理拿不到交互批准。这时改用只读命令、已有脚本或等待人工授权,通常就能继续。第三类是调用本身不合规,例如参数错了、目标路径超出仓库。它可以重试,但必须先缩小动作。

官方只承诺 PermissionDenied 可用 JSON 的 hookSpecificOutput.retry: true 告诉模型“可以重试”;如果分类器没有给出 verdict,Claude Code 会忽略这个 retry。[1] 这条限制很重要:不能把它设计成“拒绝必自动重跑”。脚本该先写明可行替代方案,retry 只是让模型有机会采用方案,不是授予权限,更不是无限重试开关。[unverified]

快速上手:给常见拒绝一张改道表#

先建一个项目级 hook。下面脚本只读取 stdin 的事件 JSON,不执行被拒绝的命令,也不改文件;它只根据拒绝信息回传下一步建议。示例故意不输出 retry,先让你观察模型是否能按提示改道:

mkdir -p .claude/hooks
cat > .claude/hooks/permission-denied-route.sh <<'SH'
#!/usr/bin/env bash
set -euo pipefail

payload=$(cat)
tool=$(printf '%s' "$payload" | jq -r '.tool_name // "unknown"')
reason=$(printf '%s' "$payload" | jq -r '.permission_denial_reason // .reason // ""')

case "$reason" in
  *network*|*网络*)
    tip='网络请求被拒绝:不要改用 curl 重试。先用已有缓存、仓库文档或请人授权网络。'
    ;;
  *write*|*写入*|*permission*)
    tip='写入被拒绝:先读取目标文件并给出最小 diff;需要落盘时请求明确授权。'
    ;;
  *)
    tip='该调用被拒绝:不要原样重试。检查工具、路径和参数;能降级为只读检查就先做只读检查。'
    ;;
esac

jq -nc --arg tool "$tool" --arg tip "$tip" \
  '{hookSpecificOutput:{hookEventName:"PermissionDenied",additionalContext:("工具 " + $tool + " 被拒绝。" + $tip)}}'
SH
chmod +x .claude/hooks/permission-denied-route.sh

然后把它写进 .claude/settings.json。PermissionDenied 的 matcher 按工具名过滤,和 PreToolUse、PostToolUse、PermissionRequest 一样;Bash|Edit|Write 是精确名称列表,而不是“任意权限事件”。[1] 先只覆盖高频工具,别用空 matcher 把每一种拒绝都塞进同一套猜测逻辑。

{
  "hooks": {
    "PermissionDenied": [
      {
        "matcher": "Bash|Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/permission-denied-route.sh",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

项目级 .claude/settings.json 可以提交给团队,用户级 ~/.claude/settings.json 则只在本机生效。[1] 用 ${CLAUDE_PROJECT_DIR} 而不是相对路径,避免 agent 切换工作目录后找不到脚本;官方也建议涉及路径占位符时用 exec form,若用 shell form 则给占位符加双引号。[1]

什么时候才加 retry#

只有一类场景我会加:脚本能根据拒绝原因给出明确、低风险、不会扩大权限面的替代动作,而且你已经用日志确认模型会收敛。比如网络被拒绝后,提示它读取项目里的 lockfile 或缓存;它下一步是 Read,不是再次请求网络。这时可以把输出改成下面这样:

jq -nc --arg tip "$tip" '{hookSpecificOutput:{
  hookEventName:"PermissionDenied",
  additionalContext:$tip,
  retry:true
}}'

别把 retry: true 放进“写入被拒绝”或“敏感命令被拒绝”的分支。原因很朴素:重试只是在同一轮继续思考,不能让一条本来不被批准的写操作突然合法。[unverified] 对没有 classifier verdict 的拒绝,它还会被平台忽略,因此你的提示必须独立成立。[1]

验收看行为,不看配置#

用 /hooks 确认事件、matcher、脚本路径和来源;这个界面只读,修改仍然要回到 JSON 文件。[1] 再启动一次受限会话,刻意触发一条无害但会被拒绝的调用,例如访问未授权测试 URL 或向受保护的临时文件写一行。检查转录里是否出现改道提示,并确认模型下一步真的改成只读检查,而非重复原命令。

把这次测试当成一份回归用例留在仓库说明里。至少记录被拒的工具、拒绝原文、期望的替代动作,以及是否应带 retry。以后有人调整权限规则或换模型,重跑同一个场景就能发现 agent 是更会收敛了,还是开始绕着限制打转。Hooks 的意义本来就是把某些动作固定在生命周期节点上,而不是指望模型每次临场记得流程。[2] 但“提示是否让模型改道”仍需要在你的实际权限配置里观察,不能只看脚本返回了 JSON。[unverified]

还要盯住一个反模式:把完整拒绝原因原样塞回上下文。路径、策略名或内部服务名可能本来就不该扩散给每个子代理。路由脚本最好只输出完成下一步所需的最小信息,例如“改成只读检查”或“请求人工授权”,详细原因留在受控日志里。[unverified]

最后留一条硬规则:PermissionDenied 负责解释失败和建议下一步;真正的限制仍放在权限规则和 PreToolUse。官方明确写了 PreToolUse 在每次工具调用前运行,并可以阻断调用。[1] 把这两个事件混成一层,最后通常会得到一个既挡不住危险操作、又把普通工作拖慢的 hook。

Sources#

[1] https://code.claude.com/docs/en/hooks — Claude Code Hooks reference [2] https://code.claude.com/docs/en/hooks-guide — Automate actions with hooks