3.2 工具与权限:allow/deny 与审批回调
3.1 把权限列为四块内置能力之一,但只说了「有 permission_mode 这么个字段」。这一节把它拆开。Agent 权限和普通应用的权限有一个根本区别:调用方是模型,而模型会「自作主张」。你没法靠 system prompt 里的「删除前请确认」约束它——提示词是建议,不是约束。真正管用的是运行时层面的闸门:模型根本看不到没被授权的工具,危险动作在执行前被硬拦下。
本节把「权限」这一层推进到可落地的粒度:分清 SDK 的四道闸门(
tools/allowed_tools/disallowed_tools/permission_mode),讲透动态审批回调can_use_tool,并给出一个「只读放行、写操作人工确认」的完整骨架。所有query()调用需要活的 Claude 会话,本节只核对 API、不执行。
3.2.1 四道闸门,各管一段
先把四个字段的分工摆清楚,避免混用。它们都在 ClaudeAgentOptions 上,本机实测默认值是:
| 字段 | 默认值 | 管什么 | 生效时机 |
|---|---|---|---|
tools | None | 本会话可用工具的集合(未列出的工具模型看不到) | 会话启动 |
allowed_tools | [] | 白名单:这些工具免审批直接放行 | 每次调用 |
disallowed_tools | [] | 黑名单:这些工具直接拒绝 | 每次调用 |
permission_mode | None | 全局档位:默认问、只读放行、全放行…… | 会话启动 |
四条闸门串成一条链:tools 先决定「有没有这个工具」,disallowed_tools 再一票否决,allowed_tools 决定「要不要问」,都没命中的就落到 permission_mode 的默认行为。理解这条链,就不会出现「我明明设了 allowed 怎么还被拒」这类困惑——因为 disallowed_tools 优先级更高,它在前面就把门关了。
上表的默认值不是抄文档,是本机 dataclasses.fields 读出来的:
import dataclasses
from claude_agent_sdk import ClaudeAgentOptions
o = ClaudeAgentOptions()
for f in dataclasses.fields(ClaudeAgentOptions):
if f.name in ("tools", "allowed_tools", "disallowed_tools", "permission_mode"):
print(f"{f.name} = {getattr(o, f.name)!r}")
tools = None
allowed_tools = []
disallowed_tools = []
permission_mode = None
tools=None 表示走 CLI 的默认工具集;白黑名单默认为空;permission_mode=None 交给 CLI 默认(等价于 default)。
顺带说明 tools 与 allowed_tools 的区别,这是最容易混的一对:tools 是能力边界(模型看不看得见这个工具),allowed_tools 是审批策略(看见了要不要问)。把危险工具从 tools 里拿掉,模型连它的存在都不知道,这是最强的隔离;而把工具放进 tools、却放进 disallowed_tools,模型会看到它但一调就被拒。前者更干净,后者可留作「看得见但用不了」的提示。
3.2.2 白黑名单的写法
allowed_tools / disallowed_tools 都是 list[str]。元素是规则字符串:基本形式是工具名,也可以带括号限定参数。读本机源码可见 CLI 侧的 tokenizer 约定:
# claude_agent_sdk/_internal/transport/subprocess_cli.py(本机实测注释)
# Parentheses and commas are delimiters to the --allowedTools tokenizer;
也就是说,SDK 把列表用逗号 join 后交给 --allowedTools,而 CLI 按「括号外的逗号与空格」切分成规则。常见形态:
allowed_tools = [
"Read", # 整个 Read 工具免审批
"Bash(git log:*)", # 只放行 git log 开头的命令
"WebSearch",
]
disallowed_tools = ["Bash(rm:*)", "Write", "Edit"]
需要诚实说明的一点:规则内部的匹配语法由 claude CLI 解析,不在 Python SDK 里。本节核对的是「列表怎么被传递」这条路径(实测 cmd.extend(["--allowedTools", ",".join(...)])),至于 Bash(git log:*) 具体怎么匹配,属于 CLI 的行为,本节未执行验证。写这类规则时,请以你本机 CLI 版本的实际行为为准——这也正是 3.3 节要讲的「锁定」风险之一。
3.2.3 permission_mode:六档全局策略
permission_mode 本机实测是六档字面量:
typing.Literal['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk', 'auto'] | None
含义与适用场景:
| 档位 | 行为 | 适用 |
|---|---|---|
default | 未在白名单的动作都要授权 | 交互式、有人的场景 |
acceptEdits | 自动接受文件编辑类动作 | 信任的本地开发 |
plan | 只读规划,不改任何东西 | 先看计划再动手 |
bypassPermissions | 全部放行 | 仅限一次性沙箱 |
dontAsk | 不弹审批,直接按规则放行 | 无人值守 |
auto | 交由运行时判断 | 托管/自动化 |
一个必须记住的反模式:bypassPermissions 不是「省事的默认值」,而是「我确定这个进程跑在一次性容器里、最坏情况无所谓」。把它设成生产默认,等于把 3.1 节说的「运行时闸门」全部拆掉,模型拿到 Bash 就能在你机器上执行任意命令。真实项目里它只应出现在 CI 的临时沙箱或本地一次性实验里。
3.2.4 can_use_tool:动态人工审批
静态白黑名单有个天然局限:它按「工具」划分,不按「这次调用的具体参数」划分。但 Bash(git push:*) 和 Bash(git push --force:*) 的危险程度天差地别,靠规则穷举所有危险参数不现实。这正是 can_use_tool 回调的用武之地——每次工具调用前,把完整参数交给你判断。
本机实测的字段类型:
can_use_tool: Callable[[str, dict[str, Any], ToolPermissionContext],
Awaitable[PermissionResultAllow | PermissionResultDeny]] | None
即回调签名是 (tool_name, input_dict, context),返回一个 awaitable 的允许/拒绝对象。两个返回类型本机实测字段如下:
PermissionResultAllow: behavior='allow', updated_input=None, updated_permissions=None
PermissionResultDeny: behavior='deny', message='', interrupt=False
PermissionResultAllow 的 updated_input 很关键:你不仅能放行,还能改写参数再放行——比如模型想 rm /important,你可以把参数改成 rm /tmp/scratch 再放行。PermissionResultDeny 的 interrupt=True 则能直接终止整轮,用于遇到明显越界时止损。
context 是 ToolPermissionContext,本机实测字段含 tool_use_id / agent_id / blocked_path / decision_reason / suggestions / signal 等。suggestions 是一组 PermissionUpdate——运行时给出的「如果批准,建议记住这条规则」的建议,你可以据此把一次性批准升级成持久规则。
PermissionResultAllow.updated_permissions 就是用来回写这些规则的。PermissionUpdate 本机实测字段含 type(addRules / replaceRules / removeRules / setMode / addDirectories / removeDirectories)、rules、behavior(allow / deny / ask)、mode 与 destination。其中 destination 决定规则写到哪里:
destination | 持久范围 |
|---|---|
session | 只在当前会话有效 |
localSettings | 本目录本地设置 |
projectSettings | 随项目提交,团队共享 |
userSettings | 当前用户全局 |
这带来一个实用的产品模式:首次危险操作弹窗,用户勾选「以后都允许」时,回写一条 addRules + destination="projectSettings" 的规则,下次同样的调用就自动放行了。也就是说,「人工审批」不是每次都问,而是可以随信任积累逐步收敛——这正是卷二 4.3 节里「审批超时与记忆」想解决的问题,SDK 把它做成了字段。
一个只读放行、写操作必须确认的回调:
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
READ_ONLY = {"Read", "Glob", "Grep", "WebSearch", "WebFetch"}
async def gate(tool_name, input_dict, context):
if tool_name in READ_ONLY:
return PermissionResultAllow()
if tool_name == "Bash":
cmd = input_dict.get("command", "")
if cmd.startswith("rm ") or "--force" in cmd:
return PermissionResultDeny(message=f"拒绝危险命令: {cmd}", interrupt=False)
# 交互式场景:这里可以 await 一个「用户点了同意吗」的 future
return PermissionResultAllow()
return PermissionResultDeny(message=f"未授权工具: {tool_name}")
注意回调是 async 的,这给了它一个天然能力:在回调里 await 一个外部事件(队列、HTTP 长轮询、前端 WebSocket),从而把「人工审批」变成真正的挂起等待,而不是忙等。这是卷二第 4.3 节「高危工具二次确认」在 SDK 里的原生对应物——那边我们手写了审批挂起逻辑,这边运行时给了钩子。
3.2.5 permission_prompt_tool_name:把审批外包给工具
如果审批逻辑复杂到需要模型参与,SDK 还留了一条路:permission_prompt_tool_name。它的类型是 str | None,读源码可见它被翻译成 CLI 的 --permission-prompt-tool:
# claude_agent_sdk/_internal/transport/subprocess_cli.py(本机实测)
if self._options.permission_prompt_tool_name:
cmd.extend(["--permission-prompt-tool", self._options.permission_prompt_tool_name])
它的语义是「把权限询问交给一个指定的 MCP 工具来回答」。也就是说,审批决策可以从「一段本地 Python」升级为「一个可被 Agent 调用的工具」——这在需要审批器自己查数据库、看工单状态的场景里有用。MCP 本身的协议细节本站已有专题(见 /posts/mcp/),本节不展开。
3.2.6 hook 与权限的关系
3.1 的 PreToolUse hook 和这里的权限系统是两条并行的拦截线,不要混淆:
| 维度 | can_use_tool | PreToolUse hook |
|---|---|---|
| 触发 | 仅在「需要审批」时 | 每次工具调用前(无论是否需审批) |
| 返回 | PermissionResultAllow/Deny | hookSpecificOutput.permissionDecision |
| 擅长 | 审批决策、改参数 | 审计日志、无条件拦截 |
hook 的 permissionDecision 支持 allow / deny / ask / defer 四档(本机实测)。allow 可以强制放行本来要问的调用,deny 可以无条件拒绝。所以顺序上:hook 是「无条件的前置检查」,can_use_tool 是「审批链上的一环」。要做「所有 Bash 都记日志、且 rm 一律拒绝」,用 hook 最合适;要做「写操作弹窗等人点」,用 can_use_tool 最合适。
3.2.7 完整骨架:只读研究助手 + 写操作审批
把四道闸门和回调拼起来,给 ResearchCopilot 一个「默认只读、写操作必须人工确认」的配置:
# research_safe.py —— 需要活的 Claude 会话才能运行,本节未执行
import anyio
from claude_agent_sdk import (
ClaudeAgentOptions, PermissionResultAllow, PermissionResultDeny,
HookMatcher, query, AssistantMessage, ResultMessage,
)
READ_ONLY = {"Read", "Glob", "Grep", "WebSearch", "WebFetch"}
async def gate(tool_name, input_dict, context):
if tool_name in READ_ONLY:
return PermissionResultAllow()
# 写类工具:交给外部审批(此处用同步占位示意)
approved = await ask_human(tool_name, input_dict) # 自定义:挂起等人工
if approved:
return PermissionResultAllow()
return PermissionResultDeny(message="人工拒绝")
async def audit(input_data, tool_use_id, context):
print(f"[audit] {input_data['tool_name']}: {input_data.get('tool_input')}")
return {}
OPTIONS = ClaudeAgentOptions(
system_prompt="你是研究助理,默认只读;需要写文件时先说明理由。",
tools=["Read", "Glob", "Grep", "WebSearch", "WebFetch", "Write", "Edit", "Bash"],
disallowed_tools=["Bash(rm:*)"],
allowed_tools=["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
permission_mode="default",
can_use_tool=gate,
hooks={"PreToolUse": [HookMatcher(matcher=None, hooks=[audit])]},
)
async def main():
async for msg in query(prompt="把 ./notes 里的资料整理成摘要", options=OPTIONS):
if isinstance(msg, AssistantMessage):
print("".join(b.text for b in msg.content if getattr(b, "type", "") == "text"))
elif isinstance(msg, ResultMessage):
print("轮数:", msg.num_turns, "拒绝记录:", msg.permission_denials)
anyio.run(main)
(本代码需要活的 Claude 会话才能运行,本节未执行,故不贴输出。)
三个设计点:只读工具进 allowed_tools 免问;disallowed_tools 把 rm 系列一刀切(它在白名单之前生效);其余写工具既不进白也不进黑,于是落到 can_use_tool 回调等人工。ResultMessage.permission_denials 还会汇总本轮所有被拒记录,审计时直接可用。
3.2.8 决策表
选哪道闸门,可以照这张表:
| 诉求 | 用什么 |
|---|---|
| 彻底不让模型知道某工具存在 | 从 tools 里移除 |
| 工具可见但一律拒绝 | disallowed_tools |
| 常用只读工具免审批 | allowed_tools |
| 改全局默认审批策略 | permission_mode |
按参数判断(如拦截 --force) | can_use_tool 回调 |
| 每次调用都记审计、且无条件拦截 | PreToolUse hook |
| 审批逻辑需要模型/外部系统参与 | permission_prompt_tool_name |
3.2.9 这套框架替你做了什么、你又让渡了什么
替你做的是一整套权限基础设施:白黑名单、六档策略、动态回调、hook 拦截、拒绝审计,全是现成的。卷二第 4.3 节我们手写的 permissions.py(工具分档 + 挂起审批 + 超时),在 SDK 里基本对应到几个字段加一个回调。
让渡的是权限语义的解释权。规则字符串(Bash(git log:*))怎么匹配、auto 档到底怎么判、defer 在什么情况下出现——这些行为由 claude CLI 定义,SDK 只是转发。你本机的 CLI 版本一升级,匹配行为可能变,而你的代码看不出变化。所以生产环境里,权限相关的回归测试必须覆盖真实 CLI,不能只测 Python 侧。这一层「行为锁定」正是下一节 3.3 与自研运行时对照:内置省了什么、锁定了什么
的主题;想回看 subagent 与 hook 的基础用法,见 3.1 核心概念:subagent/hooks/权限/session 搭研究任务
。
阅读导航:上一节:3.1 核心概念:subagent/hooks/权限/session 搭研究任务 · 下一节:3.3 与自研运行时对照:内置省了什么、锁定了什么 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。