合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 48%

第 12 章

权限与人在回路:把策略接到 Harness 上

为什么你的 can_use_tool 一次都没被调用?

第 5 章的 Policy 跑得很好。搬到 SDK,我照着文档配了 can_use_tool,跑完发现它一次都没被调用——测试还是被改了。

配置大概是这样:

python
options = ClaudeAgentOptions(
    allowed_tools=["Read", "Edit", "Bash"],
    can_use_tool=policy.can_use_tool,
)

我以为“白名单 + 回调”是双保险。实际上白名单在回调之前就把 Edit 放行了,回调根本收不到单。

SDK 其实发了一条 warning:

text
can_use_tool will not be invoked for: Read, Edit, Bash

它是 CanUseToolShadowedWarning,不是异常,不会中断运行。我没看。

这一章讲三件事:回调到底在第几关、三个真实的配置坑、以及怎么把第 5 章的 Policy 映射过来。


12.1 先看判定顺序:回调是最后一关

按当前基线,一次工具调用的判定顺序大概是:

text
PreToolUse Hook
  → deny 规则(disallowed_tools / settings)
  → ask 规则(settings)
  → 权限模式(default / acceptEdits / plan / dontAsk / bypassPermissions / auto)
  → allow 规则(allowed_tools)
  → can_use_tool 回调
  → 执行

回调是最后一关,不是第一关。

这意味着三件事:

  • 被 allow 规则、acceptEdits、bypassPermissions 批准的调用,会跳过回调。 你写在回调里的检查,对它们不生效。
  • dontAsk 模式下,回调永远不会被调用。 需要问人的调用直接拒绝;这是“无人值守只读”的配方,不是“审批”的配方。
  • 要对每一次调用都生效的检查,用 PreToolUse Hook,不要只靠 can_use_tool。 Hook 在前三关,任何模式都绕不过去。

第 5 章我们手写的 Policy 是“所有工具都过我这一关”;SDK 不是。SDK 的权限是分层的,回调只是最后一层。


12.2 三个配置事故

事故一:allowed_tools 把回调阴影掉了

就是开头那个。

python
# 坏配置:写工具进了白名单,回调收不到
options = ClaudeAgentOptions(
    allowed_tools=["Read", "Edit", "Bash"],
    can_use_tool=policy.can_use_tool,
)

allowed_tools 的语义是“免审批”,不是“可见”。你把 Edit 放进去,CLI 就直接批准,回调没有机会说话。

修法是:白名单只放绝对安全的工具,写/跑全部走回调。

python
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/,于是写了:

python
disallowed_tools=["Write(tests/**)"]

跑完发现,Edit 照样改测试。

原因:文件权限规则里,Edit(path) 管所有写文件的内置工具,包括 Write、Edit、NotebookEdit;Write(path) 永远不会被文件权限检查匹配到。

正确写法:

python
disallowed_tools=["Edit(tests/**)"]

或者用 settings 里的 permissions:

json
{
  "permissions": {
    "deny": ["Edit(tests/**)"]
  }
}

记不住语法也没关系,记住结论:按路径禁写,用 Edit(...),不要用 Write(...)。

事故三:bypassPermissions + allowed_tools 不是“只开 Read”

我见过一个配置:

python
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(人盯着)defaultGlob写/跑走回调
沙箱里快速迭代acceptEditsRead / Glob / Grep可选;文件编辑被自动批准
先出方案、再动手plan只读工具写操作走回调
无人值守只读dontAskRead / Glob / Grep不注册(注册了也不会调用)
完全放开bypassPermissions无仅限沙箱
不确定auto?不建议生产

几个容易混的点:

  • acceptEdits 只自动批准工作目录内的文件编辑。 它不会批准 Bash,也不会批准越界写。
  • plan 下,写操作会走回调,不管 allow 规则怎么写。 这是“先看方案”的强制点。
  • dontAsk 下,需要问人的调用直接拒绝。 它适合“只读 Agent”,不适合“审批 Agent”。
  • auto 的行为最不直观。 如果你说不清它批什么,就别在 production 用。

如果你只记一句:交互 coding 用 default,无人值守只读用 dontAsk,沙箱迭代用 acceptEdits,其余先别用。


12.4 can_use_tool 怎么写

签名大概是这样:

python
async def can_use_tool(
    tool: str,
    inp: dict,
    ctx: ToolPermissionContext,
) -> PermissionResultAllow | PermissionResultDeny:
    ...

返回两种:

python
PermissionResultAllow()
PermissionResultDeny(message="tests/ is read-only; fix the implementation, not the test.")

拒绝时一定要带原因。模型需要知道“为什么不行、下一步能去哪”;只说“拒绝”,它会再试一次。

第 12 章的 demo 把第 5 章的五道关搬了过来:

python
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.checkcan_use_tool + 规则 + 模式
DENIED: 原因PermissionResultDeny(message=...)
Approver.askcan_use_tool 里等人
tests/ 只读Edit(tests/**) 或回调路径检查
命令白名单allowed_tools + can_use_tool
审计日志回调里的 audit + PreToolUse/PostToolUse Hook

映射的核心是两层:

  1. 静态规则先挡。 能写成 disallowed_tools / settings 的,不要走回调;回调要等人,或者至少一次控制协议往返。
  2. 回调用来处理“需要上下文”的判断。 比如“这个路径是不是在仓库里”“这个命令是不是测试命令”“这个人批不批”。

第 5 章我们是“一个 Policy 管所有”;SDK 是“规则 + 模式 + 回调”三层。不是 SDK 更复杂,是它把“静态的”和“要问人的”分开了。


12.6 沙箱:bypass 的唯一理由

bypassPermissions 不是“省事开关”,是“我已经把进程关起来了,里面随便跑”。

沙箱要至少做到:

  • 只挂载工作目录;
  • 网络默认关闭,需要时开白名单;
  • 非 root,禁掉危险 capability;
  • 资源限制(CPU、内存、超时);
  • setting_sources=[],不加载项目里的权限配置,避免“意外放行”。

SDK 的 sandbox 选项大概长这样(以你的版本为准):

python
sandbox={"enabled": True, "allowUnsandboxedCommands": False}

关键是 allowUnsandboxedCommands=False:沙箱起不来时,宁可启动失败,也不要静默回退到“没沙箱”。

我见过最危险的情况不是“没开沙箱”,是“以为开了,其实没开”:依赖没装、权限没配,沙箱静默失效,bypassPermissions 照样全开。第 21 章会讲怎么验证沙箱真的生效。


12.7 一份权限配置检查清单

  • allowed_tools 里有没有写工具?有的话,回调是不是被阴影了?
  • 需要每次检查的路径/命令,是不是放在 PreToolUse Hook 里?
  • 按路径禁写,用的是 Edit(...) 还是错误的 Write(...)?
  • bypassPermissions 是否只在沙箱里?沙箱有没有验证真的生效?
  • dontAsk 下是不是还在期待回调?它不会调用。
  • 拒绝时有没有带原因?超时是不是默认拒绝?
  • 审批“记住”的范围是 session 还是 userSettings?后者的影响面有多大?

七条里过不了四条,先别上线。


12.8 跑一遍

完整代码在 code/sdkagent/ch12_policy.py。

bash
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 发了一条警告:

text
CanUseToolShadowedWarning: can_use_tool will not be invoked for: Glob.

这是预期的:我们把 Glob 放进 allowed_tools,它就被自动放行;回调收不到 Glob。警告不是异常,是提醒。

实际工具序列:

text
[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 基线;判定顺序以官方文档和你的实测为准。