# Step 57: 权限引擎深化
一句话导读:把 step19/35 那个「按工具名放行」的粗粒度权限,升级成真实 Claude Code 同款的规则引擎——带参数模式的 allow/deny 规则 + 文件路径越界校验,判据从「工具名」细化到「工具名 + 参数」。
# 一、这一步做了什么(What)
给权限系统换了个更精细的大脑。核心新增两个文件 + 一次 checkPerm 重写:
utils/permissions/rules.ts—— 规则引擎:把"Bash(git log:*)"这种规则串解析成结构,对每次工具调用做匹配,PermissionRules.evaluate()按 deny > allow > ask 出裁决。utils/permissions/paths.ts—— 路径校验:文件类工具(Read/Write)的路径参数不许逃出 cwd,../../etc/passwd、跨盘符绝对路径都拦。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) 无特殊符号 → 精确匹配
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; // 精确
}
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" }; // 都没命中 → 问用户
}
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 优先,无法覆盖)」喂回模型
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 风险分类器、安全沙箱、性能优化(待续)。