第 12 章
权限与人在回路:把策略接到 Harness 上
为什么你的 can_use_tool 一次都没被调用?
第 5 章的 Policy 跑得很好。搬到 SDK,我照着文档配了 can_use_tool,跑完发现它一次都没被调用——测试还是被改了。
配置大概是这样:
options = ClaudeAgentOptions(
allowed_tools=["Read", "Edit", "Bash"],
can_use_tool=policy.can_use_tool,
)我以为“白名单 + 回调”是双保险。实际上白名单在回调之前就把 Edit 放行了,回调根本收不到单。
SDK 其实发了一条 warning:
can_use_tool will not be invoked for: Read, Edit, Bash它是 CanUseToolShadowedWarning,不是异常,不会中断运行。我没看。
这一章讲三件事:回调到底在第几关、三个真实的配置坑、以及怎么把第 5 章的 Policy 映射过来。
12.1 先看判定顺序:回调是最后一关
按当前基线,一次工具调用的判定顺序大概是:
PreToolUse Hook
→ deny 规则(disallowed_tools / settings)
→ ask 规则(settings)
→ 权限模式(default / acceptEdits / plan / dontAsk / bypassPermissions / auto)
→ allow 规则(allowed_tools)
→ can_use_tool 回调
→ 执行回调是最后一关,不是第一关。
这意味着三件事:
- 被 allow 规则、
acceptEdits、bypassPermissions批准的调用,会跳过回调。 你写在回调里的检查,对它们不生效。 dontAsk模式下,回调永远不会被调用。 需要问人的调用直接拒绝;这是“无人值守只读”的配方,不是“审批”的配方。- 要对每一次调用都生效的检查,用
PreToolUseHook,不要只靠can_use_tool。 Hook 在前三关,任何模式都绕不过去。
第 5 章我们手写的 Policy 是“所有工具都过我这一关”;SDK 不是。SDK 的权限是分层的,回调只是最后一层。
12.2 三个配置事故
事故一:allowed_tools 把回调阴影掉了
就是开头那个。
# 坏配置:写工具进了白名单,回调收不到
options = ClaudeAgentOptions(
allowed_tools=["Read", "Edit", "Bash"],
can_use_tool=policy.can_use_tool,
)allowed_tools 的语义是“免审批”,不是“可见”。你把 Edit 放进去,CLI 就直接批准,回调没有机会说话。
修法是:白名单只放绝对安全的工具,写/跑全部走回调。
options = ClaudeAgentOptions(
tools=["Read", "Glob", "Grep", "Edit", "Bash"], # 可见
allowed_tools=["Glob"], # 只免审批 Glob
permission_mode="default",
can_use_tool=policy.can_use_tool,
)Read、Edit、Bash 仍然可见。Edit/Bash 会到回调;但 Read 可能被默认放行(12.8 的实测就是如此),所以别把“拦 .env”只押在 can_use_tool 上。
这里有个连带问题:你既想 Read 免审批,又想拦 .env,只靠 can_use_tool 做不到——Read 可能根本不进回调。要么用 PreToolUse Hook 拦敏感路径,要么用 settings 的 deny 规则。没有“既免审批、又每次检查”的免费午餐。
事故二:Write(路径) 规则不生效
我想禁止写 tests/,于是写了:
disallowed_tools=["Write(tests/**)"]跑完发现,Edit 照样改测试。
原因:文件权限规则里,Edit(path) 管所有写文件的内置工具,包括 Write、Edit、NotebookEdit;Write(path) 永远不会被文件权限检查匹配到。
正确写法:
disallowed_tools=["Edit(tests/**)"]或者用 settings 里的 permissions:
{
"permissions": {
"deny": ["Edit(tests/**)"]
}
}记不住语法也没关系,记住结论:按路径禁写,用 Edit(...),不要用 Write(...)。
事故三:bypassPermissions + allowed_tools 不是“只开 Read”
我见过一个配置:
permission_mode="bypassPermissions",
allowed_tools=["Read"],配置的人以为“只允许 Read”。实际上 allowed_tools 不约束 bypassPermissions;到了模式那一关,所有工具都被批准,包括 Bash、Write、Edit。
bypassPermissions 的语义是“跳过权限检查”。它只适合一种地方:你已经把整个进程关进沙箱了。
而且,即使 bypassPermissions,deny 规则和 Hook 仍然生效。所以“沙箱 + deny 规则 + Hook”是最后一道,不是 allowed_tools。
12.3 六种模式,用一个 coding agent 串起来
六种模式不是六个并列选项,是六个场景:
| 场景 | mode | 白名单 | 回调 |
|---|---|---|---|
| 交互式 coding(人盯着) | default | Glob | 写/跑走回调 |
| 沙箱里快速迭代 | acceptEdits | Read / Glob / Grep | 可选;文件编辑被自动批准 |
| 先出方案、再动手 | plan | 只读工具 | 写操作走回调 |
| 无人值守只读 | dontAsk | Read / Glob / Grep | 不注册(注册了也不会调用) |
| 完全放开 | bypassPermissions | 无 | 仅限沙箱 |
| 不确定 | auto | ? | 不建议生产 |
几个容易混的点:
acceptEdits只自动批准工作目录内的文件编辑。 它不会批准Bash,也不会批准越界写。plan下,写操作会走回调,不管 allow 规则怎么写。 这是“先看方案”的强制点。dontAsk下,需要问人的调用直接拒绝。 它适合“只读 Agent”,不适合“审批 Agent”。auto的行为最不直观。 如果你说不清它批什么,就别在 production 用。
如果你只记一句:交互 coding 用 default,无人值守只读用 dontAsk,沙箱迭代用 acceptEdits,其余先别用。
12.4 can_use_tool 怎么写
签名大概是这样:
async def can_use_tool(
tool: str,
inp: dict,
ctx: ToolPermissionContext,
) -> PermissionResultAllow | PermissionResultDeny:
...返回两种:
PermissionResultAllow()
PermissionResultDeny(message="tests/ is read-only; fix the implementation, not the test.")拒绝时一定要带原因。模型需要知道“为什么不行、下一步能去哪”;只说“拒绝”,它会再试一次。
第 12 章的 demo 把第 5 章的五道关搬了过来:
async def can_use_tool(self, tool, inp, ctx):
path = str(inp.get("file_path") or inp.get("path") or "")
if tool in {"Read", "Grep"}:
if any(s in path for s in SENSITIVE):
return PermissionResultDeny(message=f"sensitive path: {path}")
if tool in WRITE_TOOLS:
target = Path(path)
if "tests/" in target.as_posix():
return PermissionResultDeny(message="tests/ is read-only")
if tool == "Bash":
cmd = str(inp.get("command", ""))
if any(d in cmd for d in CMD_DENY):
return PermissionResultDeny(message=f"command not allowed: {cmd}")
return PermissionResultAllow()两个进阶参数,知道有这回事就行:
updated_input:改写工具输入。慎用。 我在一次实测里把out.md悄悄改写成sandbox/out.md,结果模型以为自己写的是out.md,接下来又读了两次,整次运行用了 4 次工具调用、5 次模型调用,成本是正常路径的 2.5 倍。改写输入会让模型“迷路”。updated_permissions:顺手更新权限规则。PermissionUpdate(destination="session")只在本次会话有效;写进userSettings/projectSettings会影响以后所有运行,生产代码要非常谨慎。
审批超时要默认拒绝。没人应答时,“等下去”等于“放行”,这是最危险的默认值。
12.5 把 tiny 的 Policy 映射过来
| tiny(第 5 章) | SDK |
|---|---|
Policy.check | can_use_tool + 规则 + 模式 |
DENIED: 原因 | PermissionResultDeny(message=...) |
Approver.ask | can_use_tool 里等人 |
tests/ 只读 | Edit(tests/**) 或回调路径检查 |
| 命令白名单 | allowed_tools + can_use_tool |
| 审计日志 | 回调里的 audit + PreToolUse/PostToolUse Hook |
映射的核心是两层:
- 静态规则先挡。 能写成
disallowed_tools/ settings 的,不要走回调;回调要等人,或者至少一次控制协议往返。 - 回调用来处理“需要上下文”的判断。 比如“这个路径是不是在仓库里”“这个命令是不是测试命令”“这个人批不批”。
第 5 章我们是“一个 Policy 管所有”;SDK 是“规则 + 模式 + 回调”三层。不是 SDK 更复杂,是它把“静态的”和“要问人的”分开了。
12.6 沙箱:bypass 的唯一理由
bypassPermissions 不是“省事开关”,是“我已经把进程关起来了,里面随便跑”。
沙箱要至少做到:
- 只挂载工作目录;
- 网络默认关闭,需要时开白名单;
- 非 root,禁掉危险 capability;
- 资源限制(CPU、内存、超时);
setting_sources=[],不加载项目里的权限配置,避免“意外放行”。
SDK 的 sandbox 选项大概长这样(以你的版本为准):
sandbox={"enabled": True, "allowUnsandboxedCommands": False}关键是 allowUnsandboxedCommands=False:沙箱起不来时,宁可启动失败,也不要静默回退到“没沙箱”。
我见过最危险的情况不是“没开沙箱”,是“以为开了,其实没开”:依赖没装、权限没配,沙箱静默失效,bypassPermissions 照样全开。第 21 章会讲怎么验证沙箱真的生效。
12.7 一份权限配置检查清单
-
allowed_tools里有没有写工具?有的话,回调是不是被阴影了? - 需要每次检查的路径/命令,是不是放在
PreToolUseHook 里? - 按路径禁写,用的是
Edit(...)还是错误的Write(...)? -
bypassPermissions是否只在沙箱里?沙箱有没有验证真的生效? -
dontAsk下是不是还在期待回调?它不会调用。 - 拒绝时有没有带原因?超时是不是默认拒绝?
- 审批“记住”的范围是 session 还是 userSettings?后者的影响面有多大?
七条里过不了四条,先别上线。
12.8 跑一遍
完整代码在 code/sdkagent/ch12_policy.py。
python3 -m pip install "claude-agent-sdk==0.2.168"
export ANTHROPIC_API_KEY="..."
cd code
python3 sdkagent/ch12_policy.py说明:2026-10-21 实跑(SDK 0.2.168 + CLI 2.1.296 + deepseek-flash)。
启动时 SDK 发了一条警告:
CanUseToolShadowedWarning: can_use_tool will not be invoked for: Glob.这是预期的:我们把 Glob 放进 allowed_tools,它就被自动放行;回调收不到 Glob。警告不是异常,是提醒。
实际工具序列:
[tool] Read README.md
[tool] Read tests/test_core.py
[tool] Bash ls -R ...
[tool] Read taskflow/core.py
[tool] Bash python3 -m unittest ...
[tool] Edit taskflow/core.py
[tool] Bash python3 -m unittest ...
[result] success
[audit] 3 decisions
True Bash allowed
True Edit allowed
True Bash allowed这次模型没有尝试改测试、读 .env 或 rm -rf;它读测试、跑失败、改实现、再跑绿。权限回调实际只收到 3 次调用,全部放行。
最重要的真实发现:Read 没有到回调。 尽管 Read 不在 allowed_tools 里,它被默认权限放行了;所以 .env 的检查根本没有执行。这说明 can_use_tool 不是“每次调用都过”的万能闸门:
| 调用 | 到回调了吗 | 结果 |
|---|---|---|
Glob | 否(白名单) | 放行;有 CanUseToolShadowedWarning |
Read | 否(默认放行) | 放行;.env 检查没跑 |
Edit | 是 | 放行(在仓库内) |
Bash | 是 | 放行(测试命令) |
Edit tests/ | 未发生 | 模型没试;deny 逻辑没被触发 |
Bash rm -rf | 未发生 | 模型没试;deny 逻辑没被触发 |
所以这一章的“三个攻击”是代码逻辑上的防线,不是这次运行都触发了。要拦 Read .env,别只靠 can_use_tool:用 PreToolUse Hook 或 settings 的 deny 规则;第 15 章会把 Hook 接上。
脚本没有打印 num_turns / total_cost_usd;这次只记录了工具序列和 success。别把“没打印”当成“没花钱”。
12.9 下一章:上下文与技能
权限解决“能不能做”,下一章解决“它知道什么”。
第 13 章讲 system_prompt、setting_sources、CLAUDE.md、Skill 的按需加载:为什么你写了 CLAUDE.md 它却没读,为什么 setting_sources 不是越多越好。
本章代码 权限回调:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/sdkagent/ch12_policy.py 说明:本章代码未在本机运行(环境没装 SDK/凭据);API 形状按 0.2.168 基线;判定顺序以官方文档和你的实测为准。