教程Claude Code系列第 3 / 8 篇

Claude Agent SDK 的权限、审批和 hooks:它能做什么,由你的程序说了算

智能体要改数据、要花钱的时候,放不放行不能只靠模型自己判断。本站用 Python 实测了三种把关的办法:权限模式和规则、让调用停下来等程序批准的审批回调、在工具运行前后插入检查的 hooks。小额退款自动批准、大额拒绝、冻结的订单拦下、每次调用记日志,都跑通了。附一次调用依次要过哪几道关。

发布 · 约 10 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-12。

本页目录(10 节)
  1. 一次工具调用要过哪几道关
  2. 权限模式
  3. 规则
  4. 审批回调:让调用停下来等你的程序
  5. hooks:每一次调用都看得到
  6. 回调还是 hook,怎么选
  7. 三层各管什么
  8. 这一趟花了多少
  9. 这次实测没有覆盖到的
  10. 下一步

这篇教程解决一个问题:智能体要做有后果的事(改数据、退款、删文件)时,怎么让“放不放行”由你的程序来定,而不是由模型自己觉得行就行。

上一篇的客服助手只有两种状态:退款工具要么预先批准,要么一律拒绝。真实的业务在这两头之间:小额的自动办,大额的要人点头,有纠纷的订单谁来都不能动,每一次操作都要留记录。

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 按固定的顺序检查,官方文档列的是:

  1. hooks。 先运行。hook 可以直接拒绝。
  2. 拒绝规则,来自 disallowed_tools 和设置文件。命中就拦下,任何权限模式下都一样。
  3. 询问规则,来自设置文件。命中的话,这次调用一定要问。
  4. 权限模式。 比如 acceptEdits 在这一步放行文件修改。
  5. 允许规则,来自 allowed_tools 和设置文件。命中就放行。
  6. 审批回调。 前面都没有定下来的,交给你的 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 没有实测。
  • 每种情况只跑了一次。 被拒绝之后模型怎么向用户解释,每次措辞不一样;它会不会换别的工具绕过去,取决于它手上还有什么工具。这个助手只有三个工具,没有路可绕。

下一步

到这里,每个问题都是一次性的:问一句,答一句,程序就结束了。系列的下一篇讲怎么让它记住前面聊过的内容接着聊、怎么把回答逐字显示出来,以及怎么让它按你定的结构返回数据。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章