Claude Agent SDK 的权限、审批和 hooks:它能做什么,由你的程序说了算
智能体要改数据、要花钱的时候,放不放行不能只靠模型自己判断。本站用 Python 实测了三种把关的办法:权限模式和规则、让调用停下来等程序批准的审批回调、在工具运行前后插入检查的 hooks。小额退款自动批准、大额拒绝、冻结的订单拦下、每次调用记日志,都跑通了。附一次调用依次要过哪几道关。
本站已实测,测试日期:2026-10-12。
这篇教程解决一个问题:智能体要做有后果的事(改数据、退款、删文件)时,怎么让“放不放行”由你的程序来定,而不是由模型自己觉得行就行。
上一篇的客服助手只有两种状态:退款工具要么预先批准,要么一律拒绝。真实的业务在这两头之间:小额的自动办,大额的要人点头,有纠纷的订单谁来都不能动,每一次操作都要留记录。
Agent SDK 给了三样东西来做这些事。本站接着用那个订单助手,把三样各试了一遍。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-12 |
| 版本 | claude-agent-sdk 0.2.165,自带的 Claude Code 2.1.294 |
| 系统 | Ubuntu 24.04,Python 3.12.3 |
| 模型 | Opus 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 示例 | 上一篇的订单库和三个工具:查订单、看详情、退款 |
一次工具调用要过哪几道关
先看全貌。Claude 想调用一个工具时,SDK 按固定的顺序检查,官方文档列的是:
- hooks。 先运行。hook 可以直接拒绝。
- 拒绝规则,来自
disallowed_tools和设置文件。命中就拦下,任何权限模式下都一样。 - 询问规则,来自设置文件。命中的话,这次调用一定要问。
- 权限模式。 比如
acceptEdits在这一步放行文件修改。 - 允许规则,来自
allowed_tools和设置文件。命中就放行。 - 审批回调。 前面都没有定下来的,交给你的
can_use_tool函数。dontAsk模式下没有这一步,直接拒绝。
记住两头就够用了:hook 在最前面,什么调用都看得到;审批回调在最后面,只收到前面没人管的调用。
权限模式
| 模式 | 行为 |
|---|---|
default |
没有任何自动放行。需要批准、又没有允许规则命中的调用,交给审批回调 |
dontAsk |
凡是本来要问的,一律拒绝。不会调用审批回调 |
acceptEdits |
自动接受文件修改和常见的文件系统操作 |
plan |
只探索和出计划,不改源文件 |
auto |
由一个分类器模型审每个操作 |
bypassPermissions |
不做权限检查。慎用 |
上一篇里看到过:不设权限模式时,这个版本默认用的是 auto。 文档也说了,内置的默认值可以是 auto 模式,并且在 auto 模式下,一条不带范围的 Bash 允许规则会被丢掉。你的程序如果依赖 default 模式的行为,要把它明确写出来。
default 模式而又不给审批回调,会怎样?我们试了,退款工具不在允许清单里:
[调用] mcp__shop__refund_order {"order_id": "A1003", "reason": "客户拍错了"}
[结果(报错)] Claude requested permissions to use mcp__shop__refund_order, but you haven't granted it yet.
[回答] A1003 的退款还没办成。我还没有退款工具的使用权限,所以系统把这次调用拦下了,订单状态没有变化。
没有人能回答,调用就被拒绝了,库没有变。效果和 dontAsk 一样,只是报错的措辞不同。
规则
allowed_tools 和 disallowed_tools 除了写工具名,还可以带范围,只管一部分调用。几种写法,来自官方文档:
| 写法 | 效果 |
|---|---|
allowed_tools=["Read", "Grep"] |
这两个工具自动放行 |
allowed_tools=["Bash(npm test *)"] |
只有以 npm test 开头的命令自动放行,别的 Bash 调用照常走流程 |
allowed_tools=["mcp__shop__get_*"] |
这个服务器上名字以 get_ 开头的工具 |
disallowed_tools=["Bash"] |
整个工具拿掉,Claude 看不见它 |
disallowed_tools=["Bash(rm *)"] |
工具还在,匹配的调用在任何模式下都被拒绝 |
两条容易写错的:
- 允许规则里的通配只能跟在
mcp__服务器名__后面。allowed_tools=["*"]这种写法会被忽略,什么都不会放行。 - 带路径的规则,
Edit(//secrets/**)两个斜杠开头是文件系统的绝对路径;一个斜杠开头是从工作目录算起。
这一节的规则写法我们没有逐条实测。命令行里同样的规则在把 Claude Code 接进脚本和 CI里测过。
审批回调:让调用停下来等你的程序
can_use_tool 是一个你写的异步函数。有调用走到最后一步还没定下来,SDK 就停住,把工具名和参数交给它,等它回答放行还是拒绝。
官方文档的例子是在终端里问人 y 还是 n。我们写的是一个按金额自动判断的策略,逻辑更接近真实的业务:
from claude_agent_sdk.types import PermissionResultAllow, PermissionResultDeny, ToolPermissionContext
AUTO_LIMIT_CENTS = 10000 # 100 元以内的退款自动批准
async def can_use_tool(tool_name: str, input_data: dict, context: ToolPermissionContext):
if tool_name == "mcp__shop__refund_order":
order = rows("select amount_cents from orders where id = ?", (input_data.get("order_id"),))
amount = order[0]["amount_cents"] if order else None
if amount is not None and amount <= AUTO_LIMIT_CENTS:
# 放行,并且改一下参数:在退款原因后面加上标记
return PermissionResultAllow(
updated_input={**input_data, "reason": input_data.get("reason", "") + "(系统自动批准)"}
)
return PermissionResultDeny(message="超过 100 元的退款需要主管批准。……")
return PermissionResultDeny(message=f"这个助手不允许使用 {tool_name}。")
回答只有两种:
| 写法 | 效果 | |
|---|---|---|
| 放行 | PermissionResultAllow(updated_input=...) |
工具运行。可以顺手改参数 |
| 拒绝 | PermissionResultDeny(message=...) |
工具不运行。Claude 读到这句话 |
Python 里的两处额外要求
照官方文档的例子,在 Python 里用审批回调要多做两件事,我们都照做了,没有试过去掉会怎样:
# 一,任务要用“流”的形式传进去,不能直接传字符串
async def prompt_stream():
yield {"type": "user", "message": {"role": "user", "content": question}}
# 二,要注册一个什么都不做的 hook。文档的注释说它是让流保持打开的必要变通
async def keep_open(input_data, tool_use_id, context):
return {"continue_": True}
options = ClaudeAgentOptions(
mcp_servers={"shop": shop},
tools=[],
allowed_tools=["mcp__shop__find_orders", "mcp__shop__get_order"],
permission_mode="default",
can_use_tool=can_use_tool,
hooks={"PreToolUse": [HookMatcher(matcher=None, hooks=[keep_open])]},
)
async for message in query(prompt=prompt_stream(), options=options):
...
三次运行
小额退款,自动批准。 订单 A1003 是 88 元:
[调用] mcp__shop__refund_order {"order_id": "A1003", "reason": "客户拍错了"}
[审批回调] 工具=mcp__shop__refund_order 参数={"order_id": "A1003", "reason": "客户拍错了"}
[审批回调] 金额 88.00 元,在自动批准的额度内,放行并在原因后面加上标记
[结果] 订单 A1003 已退款。原因:客户拍错了(系统自动批准)
[回答] 订单 A1003 已经退款,系统自动批准了。退款原因写的是"客户拍错了"。
工具收到的原因是回调改过的那一版,多了“(系统自动批准)”。库里这张单变成了已退款。
大额退款,拒绝。 订单 A1002 是 459 元:
[调用] mcp__shop__refund_order {"order_id": "A1002", "reason": "客户不想要了"}
[审批回调] 金额 45900,拒绝
[结果(报错)] 超过 100 元的退款需要主管批准。请告诉用户已为他登记,主管会在一个工作日内处理,
不要再尝试退款。
[回答] 订单 A1002 的退款还没退成。系统提示超过 100 元的退款要主管批准。
系统同时说已经为客户登记,主管会在一个工作日内处理。不过这是报错信息里写的,我这边没有查到
登记记录,也没看到这单的具体金额。我不会再重复提交退款。
库没有变。最后那段回答值得细看:我们在拒绝的话里写了“已为他登记”,而程序实际上并没有做登记这件事。它转述了,但同时说明这只是报错里的说法,自己没有查到登记记录。
拒绝时写的话是给模型看的,它会当真,也会核对。 只写真实发生了的事。如果要登记,就在回调里真的登记,再把单号写进这句话。
只读的查询,回调没有被调用。
[调用] mcp__shop__find_orders {"customer": "李娜"}
[结果] [{"id": "A1003", "status": "refunded", "amount_cents": 8800, "created": "2026-10-11"}]
[回答] 李娜名下只查到 1 张订单……
没有任何 [审批回调] 的行。find_orders 在 allowed_tools 里,第 5 道关就放行了,走不到第 6 道。
SDK 在启动时为这件事打了一条警告:
CanUseToolShadowedWarning: can_use_tool will not be invoked for: mcp__shop__find_orders,
mcp__shop__get_order. An allowed_tools entry that allows a whole tool auto-approves it before
the callback is consulted. To gate every tool call, use a PreToolUse hook; ……
这正是审批回调最容易被误用的地方:你以为所有调用都会经过它,其实预先批准的那些根本不来。文档里用加粗写着同一句话:自动放行的工具永远到不了 can_use_tool,你放在里面的检查会被悄悄绕过。
要每一次调用都检查,用 hook。
hooks:每一次调用都看得到
hook 是在循环的固定时刻运行的你自己的函数。和权限最相关的是两个时刻:工具运行之前(PreToolUse)和之后(PostToolUse)。
这一轮我们把退款工具放进了 allowed_tools,权限模式是 dontAsk,也就是退款已经被预先批准。然后加两个 hook:
from claude_agent_sdk import HookMatcher
LOCKED_ORDERS = {"A1002"} # 有纠纷、暂时冻结的订单
async def block_locked_orders(input_data, tool_use_id, context):
order_id = input_data["tool_input"].get("order_id")
if order_id in LOCKED_ORDERS:
return {
"hookSpecificOutput": {
"hookEventName": input_data["hook_event_name"],
"permissionDecision": "deny",
"permissionDecisionReason": f"订单 {order_id} 有未了结的纠纷,已冻结,不能退款。",
}
}
return {} # 返回空字典表示不干预
async def audit(input_data, tool_use_id, context):
line = {"time": time.strftime("%H:%M:%S"), "tool": input_data.get("tool_name"), "input": input_data.get("tool_input")}
with open("audit.log", "a", encoding="utf-8") as f:
f.write(json.dumps(line, ensure_ascii=False) + "\n")
return {}
options = ClaudeAgentOptions(
...,
allowed_tools=["mcp__shop__find_orders", "mcp__shop__get_order", "mcp__shop__refund_order"],
permission_mode="dontAsk",
hooks={
"PreToolUse": [HookMatcher(matcher="mcp__shop__refund_order", hooks=[block_locked_orders])],
"PostToolUse": [HookMatcher(matcher=None, hooks=[audit])],
},
)
几个零件:
hooks是一个字典,键是事件名,值是一组HookMatcher。matcher决定这个 hook 对哪些工具生效,按工具名匹配,"Write|Edit"这样用竖线可以写多个。传None就是每次都触发。- 回调函数收到三个参数。第一个
input_data里有事件名、工具名(tool_name)、工具的参数(tool_input)。 - 返回值是一个字典。空字典是不干预;要拒绝,就像上面那样返回
permissionDecision: "deny"和原因。
冻结的订单,被 hook 拦下:
[调用] mcp__shop__refund_order {"order_id": "A1002", "reason": "客户不想要了"}
[PreToolUse hook] 订单 A1002 在冻结名单里,拒绝
[结果(报错)] PreToolUse:mcp__shop__refund_order hook error: 订单 A1002 有未了结的纠纷,已冻结,不能退款。
[回答] 订单 A1002 的退款没办成。系统提示这张订单有未结束的纠纷,已经被冻结,所以现在不能退款。
退款工具明明在允许清单里,调用还是没有执行,库没有变。hook 排在第一道,它说不行,后面的允许规则就没有机会了。
不在名单里的订单,正常办理,并且留下记录:
[调用] mcp__shop__find_orders {"customer": "李娜"}
[调用] mcp__shop__refund_order {"order_id": "A1003", "reason": "客户拍错了"}
[结果] 订单 A1003 已退款。原因:客户拍错了
audit.log 里多了两行:
{"time": "13:31:02", "tool": "mcp__shop__find_orders", "input": {"customer": "李娜"}}
{"time": "13:31:05", "tool": "mcp__shop__refund_order", "input": {"order_id": "A1003", "reason": "客户拍错了"}}
连只读的查询也记下来了。前一次被拦下的那次退款不在日志里,因为 PostToolUse 只在工具真的运行之后触发。要连被拒绝的尝试也记,把记录放在 PreToolUse 里。
hook 还能做什么
按官方文档,除了拒绝,PreToolUse 还能改工具的参数、往对话里加一段背景;PostToolUse 能在结果后面追加说明。可用的事件 Python 和 TypeScript 不完全一样,Python 这边有 PreToolUse、PostToolUse、PostToolUseFailure、UserPromptSubmit、Stop、SubagentStart、SubagentStop、PreCompact、PermissionRequest、Notification。会话开始和结束的那两个事件只有 TypeScript 有。
还有一点容易忽略:设置文件里配的命令行 hook 也会生效。默认情况下 SDK 会加载用户和项目的设置,所以 .claude/settings.json 里已有的 hook,在你的程序里同样会跑。不想要,把 setting_sources 设成空列表。命令行 hook 的写法见Hooks 实战。
回调还是 hook,怎么选
审批回调 can_use_tool |
PreToolUse hook |
|
|---|---|---|
| 排在第几道 | 最后 | 最前 |
| 看得到哪些调用 | 只有前面没定下来的 | 每一次 |
| 预先批准的工具 | 不经过它 | 照样经过 |
dontAsk 模式下 |
不会被调用 | 照常运行 |
| 适合 | 需要人或者策略来点头的操作:停下来,问,等回答 | 一定不能发生的事、所有调用都要做的事(审计、限流、脱敏) |
官方文档的建议可以归成一句:要不管模式和规则、对每一次调用都把关,用 PreToolUse hook。
三层各管什么
把这两篇合起来看,同一个“退款”操作,把关分了三层,每层管的事不一样:
| 层 | 在哪 | 管什么 | 这次的例子 |
|---|---|---|---|
| 工具自己 | 处理函数里 | 业务上对不对 | 已签收的订单不能退 |
| hook | PreToolUse |
无论如何不能做的 | 冻结的订单谁也不能动 |
| 审批回调 | can_use_tool |
可以做,但要有人或策略点头 | 100 元以上要主管批准 |
三层不互相替代。只靠工具说明里的一句“只有某某状态能退”来约束模型,是最弱的;把规则写进代码,才是确定的。
这一趟花了多少
六次运行,每次 0.01 到 0.026 美元,合计约 0.1 美元。这些是 SDK 按官方价格估算的数字,不是中转站实际扣的金额。
这次实测没有覆盖到的
- 没有做真人审批。 回调里用的是按金额判断的策略。把请求推给一个界面、等人点了再返回,原理一样,但超时、取消这些情况没有试。
- Python 里审批回调的两处额外要求(流式传入、占位的 hook)是照文档做的,没有验证去掉会怎样。
- 带范围的规则没有逐条实测。
AskUserQuestion(让 Claude 反过来问用户问题)没有涉及。- 运行中途切换权限模式、hook 修改工具参数、
PermissionRequest和Notification事件没有试。 - TypeScript 没有实测。
- 每种情况只跑了一次。 被拒绝之后模型怎么向用户解释,每次措辞不一样;它会不会换别的工具绕过去,取决于它手上还有什么工具。这个助手只有三个工具,没有路可绕。
下一步
到这里,每个问题都是一次性的:问一句,答一句,程序就结束了。系列的下一篇讲怎么让它记住前面聊过的内容接着聊、怎么把回答逐字显示出来,以及怎么让它按你定的结构返回数据。