# Step 57: 权限引擎深化

一句话导读:把 step19/35 那个「按工具名放行」的粗粒度权限,升级成真实 Claude Code 同款的规则引擎——带参数模式的 allow/deny 规则 + 文件路径越界校验,判据从「工具名」细化到「工具名 + 参数」。


# 一、这一步做了什么(What)

给权限系统换了个更精细的大脑。核心新增两个文件 + 一次 checkPerm 重写:

  1. utils/permissions/rules.ts —— 规则引擎:把 "Bash(git log:*)" 这种规则串解析成结构,对每次工具调用做匹配,PermissionRules.evaluate() 按 deny > allow > ask 出裁决。
  2. utils/permissions/paths.ts —— 路径校验:文件类工具(Read/Write)的路径参数不许逃出 cwd,../../etc/passwd、跨盘符绝对路径都拦。
  3. index.ts 的 checkPerm 重写为固定顺序:① 路径校验 → ② deny → ③ allow → ④ ask。

启动时种入几条默认安全 deny(DEFAULT_DENY):Read(**/.env)、Read(**/*.key)、Bash(rm -rf:*)、Bash(sudo:*)…这是 Phase 6(安全底层)的开门砖:Phase 5 让 AI 能接触外部世界、能力变大,现在要给它套缰绳。


# 二、面试官视角:为什么要做?(Why)

面试题:旧权限已经能「放行 Bash / 拒绝 Bash」了,为什么还要引入规则引擎?粒度粗一点不是更简单吗?

因为**「工具名」这个判据的表达力,配不上工具本身的能力跨度**。

Bash 这一个工具,既能跑无害的 git log、ls,也能跑毁灭性的 rm -rf /。旧模型只认工具名:放行了 Bash 就等于放行所有 Bash 命令。用户想表达的策略是「日常只读命令别烦我,危险命令每次拦下」——旧模型根本无法表达这句话,只能在「全放」和「全问」之间二选一。全放不安全,全问烦到没法用。

规则引擎把判据从「工具名」升级到「工具名 + 参数模式」,于是「允许 git log、拦下 rm -rf」终于可写:

维度 旧权限(工具名) 规则引擎(工具名+参数)
表达力 只能「整个 Bash 全放/全问」 allow Bash(git log:*) + deny Bash(rm -rf:*) 并存
安全默认 要么裸奔要么烦死 默认放开也拦得住危险(DEFAULT_DENY 兜底)
越界防护 无 路径校验,文件工具锁死在 cwd 内

一句话:权限的粒度必须匹配能力的粒度——工具越强,判据就得越细。


# 三、原理:它是怎么工作的(How)

# 规则语法(三种匹配模式)

Bash                 spec 为空 → 匹配该工具的任意调用
Bash(git log:*)      :* 后缀   → 前缀匹配:命令以 "git log" 开头才命中
Read(./src/**)       含 * / ?  → glob 匹配:对文件路径通配
Read(./config.json)  无特殊符号 → 精确匹配
1
2
3
4

specMatches 就是这三条分支的直接落地:

function specMatches(spec: string, target: string): boolean {
  const s = spec.trim();
  if (s === "*" || s === "") return true;
  if (s.endsWith(":*")) {                       // 前缀匹配
    const prefix = s.slice(0, -2).trim();
    return target === prefix || target.startsWith(prefix + " ") || target.startsWith(prefix);
  }
  if (/[*?]/.test(s)) return globToRegExp(norm(s)).test(norm(target)); // glob
  return target === s;                          // 精确
}
1
2
3
4
5
6
7
8
9
10

而「拿什么去匹配」由工具决定——permTarget 从不同工具的入参里抽出「要判定的目标」:Bash 取 command,Read/Write 取 file_path,Glob/Grep 取 pattern。

# 判定铁律:deny > allow > ask

evaluate 的顺序就是安全默认的关键:

evaluate(tool, input): { decision, rule? } {
  const denied = this.rules.find(r => r.action === "deny" && ruleMatches(r, tool, input));
  if (denied) return { decision: "deny", rule: denied };      // deny 先查,命中即拒
  const allowed = this.rules.find(r => r.action === "allow" && ruleMatches(r, tool, input));
  if (allowed) return { decision: "allow", rule: allowed };   // 再查 allow
  return { decision: "ask" };                                 // 都没命中 → 问用户
}
1
2
3
4
5
6
7

# 数据流:一次调用如何走完 checkPerm

模型请求 Read({ file_path: ".env" })
   ↓
① checkPath  → .env 在 cwd 内,放行(若是 ../x 直接拒,accept 模式也拦)
   ↓
② rules.evaluate("Read", {file_path:".env"})
     deny 组: Read(**/.env) 命中 → decision = "deny"
   ↓
checkPerm 返回 false,理由「被规则拒绝:Read(**/.env)(deny 优先,无法覆盖)」喂回模型
1
2
3
4
5
6
7
8

注意路径校验和 deny 判定都排在 accept 模式短路之前(if (mode === "accept") return true 在第 ③ 步才出现)——所以 Read(.env) 即使在 accept 全自动模式下也被拦,deny 不可覆盖。

# 交互问询也升级了粒度

命中 ask 时,用户按键决定放行粒度:Y = 本会话放行这条具体调用(加一条参数级 allow:Read(.env),换个文件仍会再问);a = 放行整个工具(加 Read 裸规则)。这让「粒度」这个抽象概念在交互里被用户直接感知到。


# 四、深入追问(面试常见 follow-up)

Q:为什么是 deny 优先于 allow,而不是「后写的规则赢」或「allow 优先」? A:安全策略的默认必须是「收紧比放开更强势」。如果 allow 能盖过 deny,那管理员种下的 deny Bash(rm -rf:*) 就能被用户随手一条 allow Bash(*) 解除——禁令形同虚设。deny 优先且短路,意味着任何一条禁令都是不可覆盖的底线。这个语义到 step61 多层设置里会兑现成「enterprise 层的 deny 下层解不掉」。

Q:**/.env 里那个 **/ 到底为什么要编译成「零或多层」,而不是「一层或多层」? A:这是本步实测抓到的一个真 bug。第一版把 **/.env 编译成 ^.*/\.env$,要求 .env 前面必须有个 /;而项目根目录下的 .env 恰恰没有前导斜杠 → 规则漏过、API key 被读走。修法是 **/ 编译成 (?:.*/)?(零或多层目录,见 globToRegExp),让裸 .env 也命中。教训:通配符的「零次」边界是最容易漏测的——单测全用带目录的路径,没暴露,是 E2E 抓到的。

Q:路径校验为什么不能只靠字符串判断「有没有 ..」? A:因为 .. 可以被绕(a/../../etc)、绝对路径没有 .. 也能逃、Windows 还有盘符问题。正确做法是先 resolve 成绝对路径(把 cwd + 相对 + .. 全算平),再用 relative(root, target) 看结果是否以 .. 开头或本身是绝对路径(跨盘符时 relative 会返回绝对路径)——用规范化后的路径关系判断,而不是原始字符串里的字符。

Q:规则引擎堵得住所有危险吗? A:堵不住。deny 掉 Read(.env),模型改用 Bash(cat .env) 就绕过了——Bash 没有对应 deny。靠逐条 deny 堵等价路径是打地鼠,因为同一件危险事能用无数种工具/命令表达。这正是 step59 bash 风险分类器的动机:不看「用了哪个工具」,而看「这条命令到底想干嘛」。


# 五、踩坑 / 设计权衡

  • glob 的 * 不跨 /、** 才跨:globToRegExp 里 * 编译成 [^/]*(单段),** 才是 .*。这是标准 glob 语义,漏了这个区分会让 src/* 误匹配到 src/a/b。
  • Windows 路径分隔符:glob 匹配前两边都 norm() 把 \ 归一成 /,否则 Windows 路径和规则里的 / 永远对不上。
  • 权衡:DEFAULT_DENY 只种几条。真实版规则来自多层 settings 合并(enterprise/user/project/local),这里先硬编码几条最该无条件拦的,把「多层来源」推给 step61。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
Bash(git log:*) :* 前缀 + glob + 精确 同款规则语法(utils/permissions/)
PermissionRules.evaluate deny > allow > ask 同样的优先级
checkPath 禁止逃出 cwd pathValidation.ts(还管 symlink、~/.claude 白名单等)
DEFAULT_DENY 几条硬编码 更完整;规则来自多层 settings 合并(→ step61)
还缺:命令语义分类 bashClassifier.ts / dangerousPatterns.ts(→ step59)

# 七、一句话总结

权限引擎 = 让判据从「工具名」细到「工具名 + 参数模式」:用三种匹配(前缀/glob/精确)表达规则,用 deny > allow > ask 的短路顺序把「禁令不可覆盖」写进安全默认,再用路径校验锁死文件工具的活动范围——但它堵的是「工具+参数」,堵不住「换工具达成同一目的」,这个缺口交给下一步的语义分类器。

# 下一节预告

规则地基打好后,接下来进入 Phase 6 安全底层:bash 风险分类器、安全沙箱、性能优化(待续)。