《AI Agent 高级编程》3.2 工具与权限:allow/deny 与审批回调

上一节把权限一笔带过,本节拆开讲透。Claude Agent SDK 用四道闸门控制工具:tools 决定有哪些工具、allowed_tools / disallowed_tools 做静态白黑名单、permission_mode 定档位、can_use_tool 回调做动态人工审批。全部字段与签名现场核对 0.2.165,并给出「只读研究助手 + 写操作二次确认」的完整骨架。

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 上,本机实测默认值是:

字段默认值管什么生效时机
toolsNone本会话可用工具的集合(未列出的工具模型看不到)会话启动
allowed_tools[]白名单:这些工具免审批直接放行每次调用
disallowed_tools[]黑名单:这些工具直接拒绝每次调用
permission_modeNone全局档位:默认问、只读放行、全放行……会话启动

四条闸门串成一条链: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_toolPreToolUse hook
触发仅在「需要审批」时每次工具调用前(无论是否需审批)
返回PermissionResultAllow/DenyhookSpecificOutput.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 与自研运行时对照:内置省了什么、锁定了什么 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距