Claude Code 的权限系统是如何工作的

15 阅读20分钟

在 agent runtime 的实现里,权限从来不是外围配置项,而是执行系统的一部分。只要模型开始改文件、跑命令、调用外部工具,系统就必须回答一个核心问题:这一步是否允许执行,以及这个判断应该在执行链路的哪个位置完成。

Claude Code 的权限系统很适合作为分析样本。它不是在工具外面额外包一层确认框,而是把权限判断直接嵌入工具调用链路,让一次工具调用从发起到落地,始终伴随一套可组合、可回写的运行时裁决。

文章从一个常见工作场景切入,分析权限系统实际解决的问题、内部层次划分,以及这些设计对自建 agent 系统的参考价值。

Claude Code 的权限系统大致是在回答 4 个问题:

  • 这次工具请求本身成不成立
  • 当前上下文里,有没有针对这个工具的全局规则
  • 这个工具自己,是否还需要补充更细的风险判断
  • 如果前面都没有直接拍板,最后要怎么收口成真正的运行时结果

为了先建立这个整体直觉,可以把 Claude Code 的做法先抽象成下面这样一条执行管线。先把它当成一张“地图”就够了,具体函数名后面再一层层展开:

%% 从上到下依次表示:输入校验、通用规则预检查、工具级检查、最终收口与执行
flowchart TD
  A["LLM 产出 tool_use"] --> B["输入校验<br/>validateInput()"]
  B --> C["全局规则预检查<br/>getDenyRuleForTool()<br/>getAskRuleForTool()"]
  C --> D["工具级检查<br/>tool.checkPermissions()"]
  D --> E["模式与整体验证收口<br/>bypassPermissions mode<br/>toolAlwaysAllowedRule()"]
  E --> F["形成最终裁决<br/>allow / ask / deny<br/>passthrough -> ask"]
  F -->|"allow"| G["执行工具<br/>call()"]
  F -->|"ask"| H["向用户确认"]
  F -->|"deny"| I["生成拒绝结果"]
  G --> J["回写工具结果<br/>mapToolResultToToolResultBlockParam()"]
  I --> J

下面不按术语分块,而是直接围绕一个更贴近真实工作的场景往下走:

用户说:“帮我修一下 src/runtime.ts 里的一个小 bug,然后跑一次 npm test。”

这类请求很有代表性,因为它同时包含两种典型副作用:改文件,以及执行命令。

1. 第一个问题不是“让不让做”,而是“这次请求到底成不成立”

假设模型先产出了一个文件编辑请求:

{
  // 模型选择的工具
  "tool": "Edit",
  "input": {
    // 目标文件路径
    "file_path": "src/runtime.ts",
    // 预期被替换的原始内容
    "old_string": "if (x) return y",
    // 希望写入的新内容
    "new_string": "if (x != null) return y",
  },
}

这时一个稳妥的 runtime,通常不会立刻进入“允许还是拒绝”的判断,而是先做输入校验。对工程实现者来说,更重要的是这层职责本身:

  • 参数结构是否完整
  • 路径类型是否正确
  • 这是不是一个连工具自己都无法理解的无效请求

原因很简单。输入本身如果不成立,它就不该进入权限流程。否则你会把“坏请求”和“高风险请求”混在一起,最后既难调试,也难向模型解释失败原因。

这里最重要的心智模型是:先区分“请求是否合法”,再区分“合法请求能否执行”。

这不是为了讲解方便才硬拆出来的层次,Claude Code 的工具抽象本身就是这么设计的。下面这段代码是为了说明职责边界做的精简示意,去掉了泛型、UI 和错误处理等不影响主线的细节:

// validateInput 的返回值只回答“输入是否合法”
type ValidationResult =
  | { result: true }
  | { result: false; message: string; errorCode: number };

type Tool = {
  // 先校验输入结构和参数合法性
  validateInput?(input, context): Promise<ValidationResult>;
  // 再判断这次调用是否允许执行
  checkPermissions(input, context): Promise<PermissionResult>;
};

这段代码很短,但已经说明了一件事:“输入是否成立”“这次动作是否允许执行” 在 Claude Code 里从一开始就是两个不同阶段,而不是一个大而全的判断函数。

2. 输入合法之后,先做的是“全局规则预检查”

编辑请求校验通过以后,runtime 先做的不是立刻得出最终 allow / ask / deny,而是先跑一层比较通用的规则预检查。对应到 permissions.ts,比较核心的是这两类判断:

  • getDenyRuleForTool(...)
  • getAskRuleForTool(...)

它们的核心逻辑并不复杂,本质上就是两步:

  1. 先把当前上下文里的 deny rulesask rules 展开成规则列表
  2. 再用 toolMatchesRule(...) 看这些规则里,是否存在一条能匹配“整个工具”的规则

这里还需要把“规则从哪里来”说清楚。对运行时来说,getDenyRuleForTool(...)getAskRuleForTool(...) 读到的并不是硬编码常量,而是从 settings 体系装载出来的权限配置。

但对这一节来说,最重要的不是把配置系统的细枝末节一次讲完,而是先理解这一层到底在回答什么问题:

当前上下文里,有没有针对整个工具的预设规则?

一个最小例子大致是这样:

{
  "permissions": {
    // 这些工具命中后可直接放行
    "allow": ["Read", "Glob"],
    // 这些工具命中后必须先询问
    "ask": ["Bash", "WebFetch"],
    // 这些工具命中后直接拒绝
    "deny": ["Edit"],
  },
}

这些配置可以来自几类来源:

  • userSettings:全局用户配置,通常是 ~/.claude/settings.json
  • projectSettings:项目共享配置,通常是 .claude/settings.json
  • localSettings:项目本地配置,通常是 .claude/settings.local.json
  • policySettings:托管或企业级策略配置

2.1 多级配置如何合并

理解规则预检查时,不能只看“配置写在哪个文件里”,还要看这些文件最终是怎么合并成运行时上下文的。这里保留一个够用的心智模型就可以:

Claude Code 对 settings 的处理可以概括成两层:

  1. 先分别读取每个 source 的原始配置
  2. 再按优先级把它们合并成一份生效视图

对常见的文件型配置来说,核心优先级是:

userSettings -> projectSettings -> localSettings -> policySettings

这里的含义不是“后面的文件会把前面的整个 permissions 对象覆盖掉”,而是:

  • 普通标量字段,后面的值覆盖前面的值
  • 数组字段,会做拼接并去重
  • permissions.allow / ask / deny 这类权限数组,属于第二种情况

精简后的合并逻辑大致可以写成这样:

function mergeArrays(targetArray, sourceArray) {
  // 数组合并时不是覆盖,而是拼接后去重
  return uniq([...targetArray, ...sourceArray]);
}

function settingsMergeCustomizer(objValue, srcValue) {
  // 只有数组走自定义合并
  if (Array.isArray(objValue) && Array.isArray(srcValue)) {
    return mergeArrays(objValue, srcValue);
  }

  // 其他字段交给默认 merge 行为处理
  return undefined;
}

如果把它放到权限配置里理解,可以把结果看成这样:

// userSettings
{ "permissions": { "ask": ["Bash"] } }

// projectSettings
{ "permissions": { "deny": ["Edit"] } }

// localSettings
{ "permissions": { "allow": ["Read", "Glob"], "ask": ["WebFetch"] } }

合并后的运行时视图会更接近:

{
  "permissions": {
    // 来自 localSettings
    "allow": ["Read", "Glob"],
    // ask 数组会叠加并去重
    "ask": ["Bash", "WebFetch"],
    // 来自 projectSettings
    "deny": ["Edit"],
  },
}

policySettings 稍微特殊一点。对理解本文主线来说,你只需要知道它代表更高优先级的托管策略来源,最终也会一起进入运行时视图。

因此,权限判断里看到的 context 并不是某一个单独文件,而是一份已经经过多级来源合并后的运行时视图。

运行时会先把这些来源里的 permissions.allow / deny / ask 读出来,再转换成统一的 PermissionRule 列表。对权限判断来说,可以把这个装载过程理解成下面这样:

function settingsJsonToRules(data, source) {
  if (!data?.permissions) return [];

  return ['allow', 'deny', 'ask'].flatMap(behavior =>
    (data.permissions[behavior] || []).map(ruleString => ({
      source,
      ruleBehavior: behavior,
      ruleValue: permissionRuleValueFromString(ruleString),
    })),
  );
}

也就是说,配置文件里的字符串规则会先被解析成统一结构,再进入后面的匹配逻辑。设置来源本身也会被保留下来,因为后面不仅要判断“命中了哪条规则”,还经常要解释“这条规则来自哪里”。

对应的精简代码大致可以写成这样:

function getDenyRuleForTool(context, tool) {
  // 先取出当前上下文里的 deny 规则
  // 再找第一条能匹配整个工具的规则
  return (
    getDenyRules(context).find(rule => toolMatchesRule(tool, rule)) || null
  );
}

function getAskRuleForTool(context, tool) {
  // ask 规则的处理方式完全对称
  return getAskRules(context).find(rule => toolMatchesRule(tool, rule)) || null;
}

这里最关键的是 toolMatchesRule(...) 这层语义:它检查的是整工具匹配,不是带内容的细粒度匹配。也就是说,这一层看的规则更像:

  • Bash
  • Edit
  • mcp__server_name

而不是这类带内容的规则:

  • Bash(npm test:*)
  • Edit(src/runtime.ts)

后者属于更细粒度的内容匹配,通常要等到后面的工具级检查再处理。

比如:

  • 整个 Edit 工具是否被拒绝
  • 整个 Bash 工具是否要求先询问
  • 当前会话里有没有针对整类工具的默认限制

这一层的特点是:它偏通用,按“工具整体”或“全局规则”先做一次早筛。

所以更准确的说法不是“规则判断之后系统就已经知道最终是 allow 还是 deny 了”,而是:

  • 如果这里命中整体验证的 deny,可以直接结束
  • 如果这里命中整体验证的 ask,也可能直接要求确认
  • 如果这里没拦住,系统还要继续往下看工具自己的语义检查

这里需要特别区分的一点是:规则判断是权限流程的一层,但它不是全部。

3. 接下来才是“工具级检查”:tool.checkPermissions() 到底在做什么

假设第一步编辑已经做完,模型接着又发起一个命令:

{
  // 模型选择执行命令工具
  "tool": "Bash",
  "input": {
    // 具体要执行的命令
    "command": "npm test -- permissions",
  },
}

如果只靠上一节那层全局规则,通常还不够,因为命令类工具的风险不只来自“它是不是 Bash”,还来自“它实际会产生什么副作用”。

这时候就会进入 tool.checkPermissions()。它的作用不是重复跑一遍全局规则,而是让每个工具补上“只有我自己最清楚的风险语义”。

这一层同样会消费配置文件,只不过它读取的不再是“整工具规则”,而是更细粒度的内容规则。对于 Bash 来说,配置里的规则可以写成下面这种形式:

{
  "permissions": {
    // 整个 Bash 工具默认先询问
    "ask": ["Bash"],
    // 某些具体命令模式允许直接放行
    "allow": ["Bash(npm test:*)", "Bash(git status:*)"],
    // 某些具体命令模式直接拒绝
    "deny": ["Bash(rm -rf:*)", "Bash(curl * | sh:*)"],
  },
}

和第 2 节不同,这里看的不是 Bash 这个工具名本身,而是 Bash(...) 里面那段 ruleContent。运行时会先把这些内容规则按工具名分组,再交给工具自己的匹配逻辑处理。精简后的结构大致像这样:

function getRuleByContentsForTool(context, tool, behavior) {
  // 先筛出属于这个工具、而且带有 ruleContent 的规则
  // 例如只取出 Bash(npm test:*) 这样的内容规则
  return getRuleByContentsForToolName(
    context,
    getToolNameForPermissionCheck(tool),
    behavior,
  );
}

Bash 来说,后面的 matchingRulesForInput(...) 会继续做一层工作:把这些内容规则分成 deny / ask / allow 三组,再根据“精确匹配”或“前缀匹配”的方式去对比当前命令。因此,第 3 节讨论的不是“配置文件是否参与”,而是配置文件里的哪一类规则会在工具级检查阶段继续生效。

更适合把它理解成“工具自己补充语义判断”:

  • 文件工具更关心路径范围、敏感文件和写入目标
  • 命令工具更关心真实执行内容,而不只是字符串表面
  • 网络工具更关心目标地址和数据外发风险

这一步里,工具可能返回四类结果:

  • deny:工具自己已经能确认这次动作不能做
  • ask:工具自己认为这次动作必须先确认
  • allow:工具自己确认可以直接放行
  • passthrough:工具自己暂时不下结论,把判断交回给通用权限流程继续收口

这里的 passthrough 很关键,因为它解释了你问的第二个问题:不是规则判断一结束,就天然只剩 allow / ask / deny 它不是“允许执行”,也不是“出错了”,而是“这个工具自己先不拍板,请上层继续判断”。在真实实现里,tool.checkPermissions() 经常先返回 passthrough,然后后面的 mode、always allow 规则或者默认提示逻辑再把它收敛成最终结果。

这也是为什么命令类工具总是最麻烦。npm test -- permissions 看起来像低风险操作,但一旦换成带重定向、子命令、复合命令的写法,字符串表面就未必等于实际效果了。更克制的说法不是“它一定按某个固定顺序做 8 步判断”,而是:一个稳妥的 runtime 会尽量先把命令整理到更容易分析的形式,再叠加工具自己的安全判断。

如果先把这一层讲成人话,它想表达的顺序其实很简单:

  • 先看有没有明确命中规则
  • 再看这条命令本身有没有更具体的风险结构
  • 如果前面都没拦住,再看有没有补充放行条件
  • 工具自己还是拿不准,就把判断交回上层继续收口

Bash 的权限检查能把这种“分层递进”的结构表现得比较完整。下面这段代码保留了 bashPermissions.ts 的判断顺序,删掉了建议规则、错误处理和边界分支:

%% Bash 工具内部的权限判断顺序,与下面的伪代码逐行对应
flowchart TD
  A["进入 Bash.checkPermissions()"] --> B["精确命中规则<br/>bashToolCheckExactMatchPermission()"]
  B -->|"deny / ask"| C["直接返回"]
  B -->|"allow / passthrough"| D["前缀规则匹配<br/>matchingRulesForInput(..., 'prefix')"]
  D -->|"deny / ask"| C
  D -->|"未命中 deny / ask"| E["路径约束检查<br/>checkPathConstraints()"]
  E -->|"deny / ask"| C
  E -->|"passthrough"| F["判断 exact.behavior === 'allow'"]
  F -->|"是"| C
  F -->|"否"| G["判断 matchingAllowRules[0]"]
  G -->|"命中 allow"| C
  G -->|"未命中"| H["模式补充判断<br/>checkPermissionMode()"]
  H -->|"allow / ask / deny"| C
  H -->|"passthrough"| I["只读命令判断<br/>BashTool.isReadOnly()"]
  I -->|"allow"| C
  I -->|"否"| J["返回 passthrough<br/>交回通用权限流程"]
export const bashToolCheckPermission = (input, permissionContext) => {
  // 先检查最严格的“精确命中”规则
  // 例如当前命令正好命中 Bash(npm test -- permissions) 或 Bash(rm -rf /tmp/demo)
  const exact = bashToolCheckExactMatchPermission(input, permissionContext);
  if (exact.behavior === 'deny' || exact.behavior === 'ask') return exact;

  // 再检查更宽一点的前缀规则
  // 例如 Bash(npm test:*)、Bash(git status:*)、Bash(rm -rf:*)
  const { matchingDenyRules, matchingAskRules, matchingAllowRules } =
    matchingRulesForInput(input, permissionContext, 'prefix');

  // deny / ask 一旦命中,立即结束
  // 例如 Bash(rm -rf:*) 命中 deny,或 Bash(npm publish:*) 命中 ask
  if (matchingDenyRules[0]) return { behavior: 'deny' };
  if (matchingAskRules[0]) return { behavior: 'ask' };

  // 然后检查路径约束,例如是否越过工作区、是否碰到敏感路径
  // 例如 cat ../secrets.txt、echo hi > .claude/settings.json
  const pathResult = checkPathConstraints(input, getCwd(), permissionContext);
  if (pathResult.behavior !== 'passthrough') return pathResult;

  // 允许规则要在前面的风险检查之后再生效
  // 例如精确规则明确允许 Bash(npm test -- permissions)
  if (exact.behavior === 'allow') return exact;
  // 例如前缀规则允许 Bash(npm test:*) 或 Bash(git status:*)
  if (matchingAllowRules[0]) return { behavior: 'allow', updatedInput: input };

  // 最后再看 mode 和“只读命令”这样的补充放行条件
  // 例如在 bypassPermissions 模式下直接放行,或在受限模式下要求 ask
  const modeResult = checkPermissionMode(input, permissionContext);
  if (modeResult.behavior !== 'passthrough') return modeResult;

  // 只读命令可以作为最后一道补充放行
  // 例如 ls -la、pwd、git status
  if (BashTool.isReadOnly(input)) {
    return { behavior: 'allow', updatedInput: input };
  }

  // 工具自己没有最终意见时,交回通用权限流程继续收口
  // 例如 npm test 既没命中 allow/deny/ask,也不是纯只读命令
  return { behavior: 'passthrough' };
};

这段代码最值得关注的不是语法,而是判断顺序本身:

  • 先看最明确的规则命中
  • 再看路径和命令语义这类工具特有风险
  • 最后才落到 mode 和只读判断
  • 如果工具自己也没有明确放行或拒绝,就先返回 passthrough

这也是“工具级检查”真正的意义。它不是重复造一遍全局权限系统,而是把那些只有工具自己最清楚的风险补上。

3.1 为什么 Shell 权限判断不能只看字符串

之所以这里要专门展开,是因为命令工具的“内容规则匹配”只有建立在更可靠的语法理解上,前面那套判断顺序才真正站得住。

Bash 这类工具里,光靠字符串切分通常不够,因为很多 Shell 语义只有在语法层面才能稳定区分。tree-sitter 在这里的作用,不是简单地把命令拆成 token,而是把命令解析成 AST,让系统能看到更可靠的结构信息,比如:

  • 有没有真正的操作符节点,如 &&||;
  • 有没有 pipeline、subshell、command substitution、heredoc
  • 某段内容是在引号里、在参数里,还是已经构成了独立命令结构

这类信息直接影响权限判断。下面这组例子更能说明问题:

# 这些都包含 rm,但语义完全不同
rm -rf /                    # 真正执行删除,极度危险
echo "rm -rf /" > log.txt   # 写入字符串,本质上不是执行 rm
grep "rm" history.txt       # 搜索文本,本质上不是执行 rm
find . -name '*.tmp' -exec rm {} \;  # rm 出现在 find -exec 结构里

如果只做字符串匹配,这几条命令都可能因为包含 rm 而被粗暴归成一类;但从语法结构看,它们分别是:

  • 一个真正的危险删除命令
  • 一个带重定向的 echo
  • 一个文本搜索命令
  • 一个 find -exec 复合结构

再看另一组例子:

cd src && npm test          # 顶层操作符,表示两条串联命令
echo 'cd src && npm test'   # 只是字符串,不是串联执行
echo $(git status --short)  # 含 command substitution,内部仍有可执行结构

这里如果只按字符去找 &&$(,也很容易误判。真正有用的不是“字符串里出现了什么符号”,而是这些符号在语法树里扮演什么角色。

tree-sitter 的价值就在于,它让系统能按语法结构理解 Shell,而不是只靠字符串切分或正则近似猜测。对于权限判断来说,这意味着系统判断的不是“这串字符像不像危险命令”,而是“这条 Shell 语句在语法上到底由哪些结构组成”。

4. 经过收口之后,系统才形成最终的 allow / ask / deny

前两节讲完以后,权限流程其实还没结束。Claude Code 在 hasPermissionsToUseToolInner(...) 里还会继续做两类收口:

  • mode 是否允许直接绕过后续权限提示,比如当前处在 bypassPermissions
  • 整个工具是否命中了 always allow 这类整体放行规则,比如 toolAlwaysAllowedRule(...)

如果前面 tool.checkPermissions() 返回的是 passthrough,后面的收口逻辑还会把它统一转换成 ask。更准确的理解应该是:

最终的 allow / ask / deny,是整条权限管线收敛后的结果,不是某一层单独拍板的结果。

这时最值得记住的不是内部函数名,而是这三个运行时结果:

  • allow:直接执行
  • ask:暂停并向用户确认
  • deny:不执行,直接生成拒绝结果

这三种结果里,最容易被误解的是 ask。它不是失败,更像“当前上下文不足以自动放行,所以把决定权抛回给用户”。如果用户确认了,请求可以重新回到同一条执行管线;如果用户拒绝,系统就把这次动作收束为一次明确的拒绝。

换句话说,权限系统不是“执行前拦一下”这么简单,它更像一个会改变后续推理状态的 runtime 分叉点。

5. 为什么“回写工具结果”这一步不能省

很多新人理解权限系统时,只盯着前半段:怎么拦,怎么放。真正让 agent 能继续工作的,往往是后半段:这次动作的结果要怎么回到模型上下文里。

继续沿着这个场景看:

  • 如果文件改动成功,模型需要知道“改好了”,这样它才会继续决定要不要跑测试、要不要总结改动。
  • 如果测试命令被拒绝,模型也需要收到一个结构化结果,知道“为什么没跑成”,以及下一步更适合改成什么动作。

更合理的 runtime 会把这类结果写回消息流。这里同样不必把实现细节写得过重,抓住抽象就够了:无论是执行成功、等待确认,还是被拒绝,系统都应该把结果结构化地送回模型。

这一段的重点也不是让你记字段名,而是看清楚:回写结果至少要把“有没有执行”“为什么没执行”“原因来自哪一层判断”这几类信息带回去。

以“命令被拒绝”为例,回写给模型的结果大致可以理解成下面这种结构:

{
  // 这次工具调用最终没有执行
  "behavior": "deny",
  // 给模型看的拒绝说明
  "message": "Permission to use Bash with command rm -rf / has been denied.",
  "decisionReason": {
    // 拒绝是由规则触发的
    "type": "rule",
    "rule": {
      // 规则来源,例如 localSettings / projectSettings / session
      "source": "localSettings",
      // 这是一条 deny 规则
      "ruleBehavior": "deny",
      "ruleValue": {
        // 作用于 Bash 工具
        "toolName": "Bash",
        // 命中的内容规则
        "ruleContent": "rm -rf:*",
      },
    },
  },
}

这个结构的关键不在字段名本身,而在它把三件事都明确带回了模型:

  • 这次动作没有执行
  • 没执行的原因是什么
  • 原因来自哪一类规则或判断

这样模型下一步才能基于失败原因继续推理,比如改用更安全的命令、向用户申请确认,或者直接解释为什么这一步被拦住了。

如果没有这一步,模型只会感知到“动作没发生”,却不知道是输入无效、权限不足,还是用户刚刚拒绝了它。后续行为就容易发散。

6. 用一段精简过的真实代码,把这条权限管线串起来

前面看的是某个具体工具内部怎么细化判断。再往上一层,Claude Code 在通用权限管线里也有一个很清楚的结构。下面这段代码同样是为了解释主流程做的精简示意,保留的是核心决策顺序,并且和开头那张流程图一一对应:

async function hasPermissionsToUseToolInner(tool, input, context) {
  // 1. 全局规则预检查:整个工具是否被 deny
  const denyRule = getDenyRuleForTool(context, tool);
  if (denyRule) {
    return { behavior: 'deny', decisionReason: { type: 'rule' } };
  }

  // 2. 全局规则预检查:整个工具是否要求先 ask
  const askRule = getAskRuleForTool(context, tool);
  if (askRule) {
    return { behavior: 'ask', decisionReason: { type: 'rule' } };
  }

  // 3. 工具级检查:交给工具自己补充语义判断
  // 注意:这里先把输入解析成工具期望的结构
  const parsedInput = tool.inputSchema.parse(input);
  const toolPermissionResult = await tool.checkPermissions(
    parsedInput,
    context,
  );

  // deny / ask 会直接作为最终结果向上返回
  if (toolPermissionResult.behavior === 'deny') return toolPermissionResult;
  if (toolPermissionResult.behavior === 'ask') return toolPermissionResult;

  // 4. 收口:mode 是否允许直接放行
  if (context.mode === 'bypassPermissions') {
    return { behavior: 'allow', updatedInput: parsedInput };
  }

  // 5. 收口:整个工具是否命中 always allow
  if (toolAlwaysAllowedRule(context, tool)) {
    return { behavior: 'allow', updatedInput: parsedInput };
  }

  // 6. 最终裁决:passthrough 会被收口成 ask
  // 只有真正明确 allow 的结果,才会在这里继续保留为 allow
  return {
    behavior: toolPermissionResult.behavior === 'passthrough' ? 'ask' : 'allow',
    updatedInput: parsedInput,
  };
}

如果把开头的流程图和这段代码对照着看,关系会更清楚:

  1. 输入校验
  2. 全局规则预检查
  3. 工具级检查
  4. mode / always allow 收口
  5. 形成最终 allow / ask / deny
  6. 执行工具或生成拒绝结果
  7. 回写工具结果

你会发现,真正决定行为的不是某一个单点函数,而是几层判断逐步收敛。这样设计的好处是,每一层都只负责自己最擅长的那部分语义,最后再把结果收成统一的运行时裁决。

总结

如果只看表面现象,Claude Code 的权限系统像是在工具外面加了一层“允许 / 拒绝”的开关;但顺着执行链路拆开以后,会发现它实际是一个分层运行的判断过程:先做输入校验,再做全局规则预检查,再进入工具级检查,最后把结果收口成 allow / ask / deny,并把结果回写给模型。

这套机制的关键,不在某一个单独函数,而在这些层次的分工。配置文件里针对整工具的规则、带内容的细粒度规则、Bash 自己的语义判断,以及最终裁决后的结果回写,一起构成了完整闭环。也正因为如此,权限系统讨论的从来不只是“能不能执行”,还包括“为什么被拦住”“拦住之后模型会收到什么”。

从这个角度看,Claude Code 的权限系统并不是一个独立的安全补丁,而是工具执行系统的一部分。它和配置加载、Shell 解析、规则匹配、结果回写共同组成了一条完整的执行管线;理解这一点,也就能更准确地理解前面这些判断步骤为什么要以现在这样的顺序出现。