博客 • AI / Agent
Claude Agent 权限控制怎么实现?从 Permission Policy、MCP Tool Call 到 JSON 审批流程完整解析
Claude Agent 的权限控制,是插在模型提出的 Tool Call 和真正执行之间的一层 JSON 判定。策略给出 allow、deny 或 ask;ask 落成审批记录;决定再写回同一次 tool_use_id。漏写或写错 id,会话会停住,或者同一次调用被执行两遍。
平台如何自动判断,写在上一篇。那里的 auto 由服务器读 permission_policy 后写出 evaluated_permission,判定过程你改不了。本篇只写你要接上的三份 JSON:策略、审批队列、拒绝回写。自定义工具,以及回调里才看得到的调用,都走这里。
本文将介绍:
- 官方评估走到哪一步,
canUseTool才会被调用 - Permission Policy JSON 的字段、默认值和匹配顺序
- MCP Tool Call 如何先过 Schema,再对上规则
- 审批队列记录哪些字段,过期以后怎么处理
- 同意和拒绝怎样写回,避免同一次调用执行两遍
先记住这一点:把某条工具写成 allow,审批队列就看不见它。canUseTool 不会在已经放行的调用上触发。下面按「接入点 → 策略 JSON → 队列 → 回写」拆你要自己留下的文档。
你接在官方评估的哪一步
Agent SDK 和 Claude Code 的评估顺序是固定的。依据见 Agent SDK 权限 和 Claude Code 权限。规则先看 deny,再看 ask,再看 allow。更具体的 allow 不能盖过已经命中的 deny。
-
1
deny 先拦
规则命中就拦住,
bypassPermissions模式也一样。只写工具名的 deny(例如整条 Bash)会在这一步之前把工具从上下文里拿掉。 -
2
ask 进入回调
规则命中就交给
canUseTool,即便开了绕过模式。在dontAsk里没有提示,命中的调用直接拒绝,不再回调。 -
3
allow 不再询问
规则命中就直接执行,
canUseTool不会被调用。只把工具名放进允许名单,效果相同,除非另有 ask 规则把它拉回回调。 -
4
都没命中
前面都没命中时,
default模式才把调用交给canUseTool。dontAsk则直接拒绝,不再回调。
因此审批队列只能接住还没被放行的调用。若要对每一次调用都记账,用 PreToolUse hook。它跑在其余检查之前,可以允许、拒绝或改参数,但盖不过已经命中的 deny 或 ask 规则。
三种接入共用同一份队列记录,最后映射到不同的官方回写。
| 接入 | 你要保存的 JSON | 写回给模型的形状 |
|---|---|---|
| Managed Agents 托管工具,停在 ask | permission_policy |
user.tool_confirmation |
| Agent SDK 回调 | 你的 policy,加队列记录 | { behavior, updatedInput / message } |
| 自定义工具 | 你的 policy,加队列记录 | user.custom_tool_result |
自定义工具的事件是 agent.custom_tool_use,没有 evaluated_permission。平台文档见 Managed Agents 权限策略。托管工具被 auto 判成 deny 时,客户端不能用确认事件推翻。队列不要做成第二条强制放行通道。
Permission Policy JSON 怎么写
只跑 Claude Code 时,官方 settings.json 里的 allow / ask / deny 字符串就够。产品里的 Agent 还要一份能版本化、能 diff、能用 Schema 校验的文档。下面这份是你的应用契约,不是 Anthropic 的请求体。
{
"version": 1,
"default": "ask",
"rules": [
{
"id": "deny-env",
"effect": "deny",
"tool": "Read",
"input": { "file_path": { "const": ".env" } },
"reason_code": "secret_file"
},
{
"id": "ask-pr-main",
"effect": "ask",
"tool": "mcp__github__create_pull_request",
"input": { "base": { "enum": ["main", "master"] } },
"reason_code": "protected_branch"
},
{
"id": "allow-docs-search",
"effect": "allow",
"tool": "mcp__docs__search"
}
]
}
| 字段 | 含义 |
|---|---|
version |
策略格式版本。写进队列记录,避免用新规则解释旧单。 |
default |
没有任何规则命中时的效果。产品里用 ask 或 deny,不要用 allow。 |
effect |
deny、ask 或 allow |
tool |
工具全名。MCP 工具以 mcp__ 加服务器名,再加工具名。 |
input |
可选。写出时,参数也对上才命中。省略表示只看工具名。 |
reason_code |
稳定机器码。写进回写和审计,不要用一句自然语言当主键。 |
匹配顺序和官方一致:先扫全部 deny,再 ask,再 allow。同一 effect 内,文件里靠前的那条先生效。
把 default 设为 ask 时,没写过的新 MCP 工具会进队列,而不是直接执行。这和托管 MCP toolset 默认 always_ask 是同一思路,但是你的文档、你的队列。
一次 MCP Tool Call 怎么对上规则
模型给出的调用至少带类型、id、工具名和 input。字段名以 Messages API 的 tool_use 为准。MCP 工具名是服务器名和工具名拼出来的全名。
{
"type": "tool_use",
"id": "toolu_01AbC",
"name": "mcp__github__create_pull_request",
"input": {
"base": "main",
"title": "Retry failed checks",
"head": "fix/retry"
}
}
-
1
先校验形状
用该工具自己的 JSON Schema 校验 input。校验失败:不入队、不调用 MCP,直接 deny,reason_code 用
schema_invalid。 -
2
再按 deny、ask、allow 匹配
上面这条命中
ask-pr-main,因为base落在 enum 里。同一 effect 内才看文件顺序。 -
3
不要只写工具名就 allow
若 allow 规则没有 input 约束,base 为 main 也会被放行。参数约束是权限的一部分,不是 Schema 的重复。
function decide(policy, call) {
const order = ["deny", "ask", "allow"];
for (const effect of order) {
const hit = policy.rules.find(
(rule) => rule.effect === effect && matchRule(rule, call)
);
if (hit) {
return {
effect,
rule_id: hit.id,
reason_code: hit.reason_code || null
};
}
}
return { effect: policy.default, rule_id: null, reason_code: "default" };
}
function matchRule(rule, call) {
if (rule.tool !== call.name) return false;
if (!rule.input) return true;
return Object.entries(rule.input).every(([key, cond]) => {
const value = call.input?.[key];
if (Object.prototype.hasOwnProperty.call(cond, "const")) return value === cond.const;
if (Array.isArray(cond.enum)) return cond.enum.includes(value);
return false;
});
}
路径前缀要另写检查器,只比较规范化之后的路径。上面的函数遇到不认识的条件会返回 false,这条规则不命中,而不是放行。
| JSON Schema | Permission Policy | |
|---|---|---|
| 回答的问题 | 参数形状对不对 | 这一次能不能执行 |
| 失败时 | schema_invalid,不入队 |
deny / ask |
| 例子 | base 必须是字符串 |
base 为 main |
审批队列要存什么
effect 为 ask 时,先落库再等人。不要把「正在等」只放在内存里。进程一重启,模型侧那次调用就没有对应结果。
{
"id": "apr_01HZXK",
"session_id": "sess_01",
"tool_use_id": "toolu_01AbC",
"tool_name": "mcp__github__create_pull_request",
"input": {
"base": "main",
"title": "Retry failed checks",
"head": "fix/retry"
},
"matched_rule": "ask-pr-main",
"policy_version": 1,
"status": "pending",
"created_at": "2026-09-23T09:00:00Z",
"expires_at": "2026-09-23T09:15:00Z"
}
tool_use_id唯一。同一个 id 再来,返回已有记录,不要再调一次 MCP。- status 只允许从
pending走到approved、denied或expired。终态不可改。 - 过了
expires_at,把它标成expired,按deny回写,reason_code 用approval_expired。不要默认放行。 - input 原样保存,给审批人看。策略文件不要从 input 里读。模型不能通过参数改规则。
人的决定单独存一份,不写进队列记录本身。
{
"approval_id": "apr_01HZXK",
"tool_use_id": "toolu_01AbC",
"decision": "deny",
"actor": "user:alex",
"reason_code": "protected_branch",
"decided_at": "2026-09-23T09:04:12Z"
}
actor 来自你的登录会话,不要采信 input 里自带的用户字段。
拒绝和同意怎么写回
决定一旦进入终态,必须写回同一次 tool_use_id。漏写的结果是会话停在等待,模型既不能继续,也可能在下一轮用新的 id 再提一次。
| 你的决定 | Agent SDK 回调 | 托管工具停在 ask | 自定义工具 |
|---|---|---|---|
allow |
{ behavior: "allow", updatedInput } |
result: "allow" |
你执行成功后,回自定义工具结果 |
deny |
{ behavior: "deny", message } |
result: "deny" + deny_message |
带错误标记的自定义工具结果,写上 reason_code |
见 官方用户输入文档。文档写明,TypeScript 的 allow 要带 updatedInput,Python 要带 updated_input。Claude Code v2.1.207 之前,省略该字段会被校验拒绝。回写时始终带上原 input。
{
"behavior": "deny",
"message": "Denied by rule ask-pr-main (protected_branch). Do not retry this call."
}
message 和 deny_message 给模型看。写规则 id 和 reason_code,不要写内部主机名、令牌或审批人的私人备注。
{
"tool_use_id": "toolu_01AbC",
"effect": "deny",
"rule_id": "ask-pr-main",
"reason_code": "protected_branch",
"wrote_back": true
}
只有 allow 之后才调用 MCP。deny 和 expired 不调用。策略引擎不负责先执行、再看人批不批。
和 settings、auto 怎么分工
三套规则回答的不是同一件事。不要各写各的名单,再指望它们自动对齐。
| Claude Code settings | Managed Agents | 你的 policy 和队列 | |
|---|---|---|---|
| 规则形状 | Tool 加说明的字符串 | always_allow / always_ask / auto |
effect,加上工具名和可选的 input 约束 |
| 谁评估 | Claude Code,顺序是 deny、ask、allow | 平台。auto 按这一次调用评估 | 你的代码。顺序与官方对齐 |
| 人批落在哪 | 交互提示,或 canUseTool | user.tool_confirmation |
你的队列,再映射到左边两种回写 |
| 适合 | 本机编码 | 托管 Agent | 产品要留下审批单和审计 |
{
"permissions": {
"allow": ["Bash(npm test *)"],
"ask": ["Bash(git push *)"],
"deny": ["Read(./.env)", "Read(./.env.*)"]
}
}
必须人看的工具,effect 保持 ask。auto 在判定安全时会在人看见之前执行,细节见 上一篇。产品里以你的 policy 为源;导出成 Claude Code 的 deny 字符串只是一种投影,审批状态仍以队列为准。
在 JSONNote 里检查这三份 JSON
上线前用工作区把策略、队列记录和回写各过一遍。这三份都能在浏览器里格式化、校验和对比,不必先接到线上 Agent。
-
1
格式化策略
用 JSON 格式化 打开 policy。Claude Code 的 settings 也是严格 JSON,注释和尾逗号都会在下次启动时报错。
-
2
校验队列记录
把下面的 Schema 贴进 JSON Schema 工具,再贴一条审批记录。缺 tool_use_id、status 不在枚举里,都会失败。
-
3
对比改过的规则
改规则前后用 JSON Diff 看
ask有没有变成allow。这种变化比措辞更值得人看。 -
4
校验回写
用 JSON 校验 确认回写仍是合法 JSON,并且
tool_use_id还在。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"id",
"session_id",
"tool_use_id",
"tool_name",
"input",
"status",
"created_at",
"expires_at"
],
"properties": {
"id": { "type": "string", "minLength": 1 },
"session_id": { "type": "string", "minLength": 1 },
"tool_use_id": { "type": "string", "minLength": 1 },
"tool_name": { "type": "string", "minLength": 1 },
"input": { "type": "object" },
"matched_rule": { "type": ["string", "null"] },
"policy_version": { "type": "integer", "minimum": 1 },
"status": { "enum": ["pending", "approved", "denied", "expired"] },
"created_at": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" }
}
}
常见问题
canUseTool 为什么没有被调用?
因为更早的 allow 规则,或 acceptEdits、bypassPermissions 已经放行。回调只处理还没被批准的调用。每一次都要拦截,用 PreToolUse;它仍然盖不过 deny。
只校验 JSON Schema 够不够?
不够。Schema 只证明参数形状合法,例如 base 是字符串。base 为 main 时要不要人批,是 policy 的事。形状合法仍可能是 deny 或 ask。
拒绝之后要不要让模型用同一参数重试?
不要。回写里写明不要用同一参数重试,也不要删掉结果假装没发生。新的尝试必须是新的 tool_use_id,并重新走队列。
队列过期了会默认放行吗?
不会。把记录标成 expired,按 deny 回写,reason_code 用 approval_expired。过期不是同意。
Managed Agents 的 auto 还要不要这套队列?
要,但只接 always_ask 和自定义工具。auto 判成 deny 的调用,客户端不能用确认事件推翻。队列不要做成强制 allow 的旁路。
总结
权限控制落地就是三份 JSON:你写的 policy、ask 时落下的队列记录、终态时写回的决定。匹配顺序跟官方一样,deny 优先。Schema 只管形状。人还没批,或者已经过期,都不调用 MCP。
直接结论:默认用 ask 或 deny;allow 必须写到工具名,需要时再加参数;每一条 tool_use_id 只执行一次,并且一定写回。
MCP 工具谁负责声明、谁负责执行,见 MCP 安全。为什么 JSON 要挡在执行前面,见 安全边界。
下一步:先校验审批记录的形状,再把策略交给 Agent。