本节摘要:权限设计的第一课是最小权限:默认不给,按需授予,授予到刚好够用。而"够用"的计量单位不是工具,是工具 × 作用域——"允许 bash"不是一条权限,"允许在工作区内执行 git status 类只读命令"才是。本节先立三级判定模型(deny 优先硬拒、allow 白名单静默放行、其余默认 ask 问人——Claude Code 公开设计的同款结构,社区),再给出 YAML 配置示意:按第 4.1 节工具分类学的风险分级逐层展开,路径作用域用工作区相对路径,命令用"程序 + 参数模式"双重匹配;然后拆解命令前缀匹配的经典陷阱(
git push放行了,git push --force呢?),落到参数级匹配与结构化命令解析的解法。
阅读完本节,你应当能够:
registry.dispatch 的挂点。新手权限系统的通病是按工具名授权:"允许 bash、允许写文件"。但工具名的粒度配不上风险的粒度——bash ls 与 bash rm -rf / 是同一个工具。正确的判定单位:
| 维度 | 问题 | 例子 |
|---|---|---|
| 工具 | 哪个能力 | bash / write_file / web_fetch |
| 路径作用域 | 哪个范围 | 仅工作区内 / 仅 src/** / 任意 |
| 参数模式 | 什么形状 | git status、git diff *;写操作仅 *.py |
| 网络作用域 | 去哪 | 仅内网 registry / 仅 api.openai.com / 任意 |
三级判定模型(Claude Code 公开权限设计的同构,社区资料):
每个工具调用 → ① deny 规则命中? → 命中即拒(不给任何绕过机会) ② allow 规则命中? → 静默放行(+ 记日志) ③ 都不命中 → ask:进入审批流(5.2 节)
为什么 deny 必须优先于 allow:deny 表达的是政策("任何时候不得触碰凭证目录"),allow 表达的是便利("这些常规操作免问")——便利永远不该压过政策。为什么默认是 ask 而不是 allow:最小权限——没被显式授权的,就是待授权的。
# permissions.yaml —— 权限配置示意(写法示意,以实际工程为准) # 结构:deny(政策红线)> allow(免问白名单)> 其余默认 ask deny: - tool: bash # 政策:任何强制推送与凭证读取,一律硬拒 args_match: "git push --force*" - tool: bash args_match: "*cat ~/.ssh/*" - tool: read_file path: "**/.env*" # 凭证文件不许进上下文(防外泄于未然) allow: - tool: read_file # 读类:工作区内免问(4.1 分类学:低危) path: "workspace/**" - tool: grep # 检索类:免问 - tool: write_file # 写类:仅源码目录免问,其余 ask path: "workspace/src/**" - tool: bash # 执行类:只放行明确枚举的只读命令 argv: ["git", "status"] - tool: bash argv: ["pytest", "-q"] # 测试是高频刚需,值得一条 allow # 未列出的(如 rm、curl、任意 npm script)→ 全部走 ask
配置的三个设计决定值得点破:读与写分开授权(写多了一层路径限制);执行类用"程序 + 参数"双重匹配而不是裸前缀(下一节);deny 里放的是政策不是便利——每一行 deny 都应该能追溯到一条安全要求。
字符串前缀匹配(allow "git push")是最常见也最危险的实现:
| 陷阱 | 攻击面 |
|---|---|
| 子命令升级 | 放行 git push → git push --force 同前缀通过 |
| 参数注入 | 放行 npm test → npm test; rm -rf / 同前缀通过 |
| 路径穿越 | 放行 read workspace/** → workspace/../.ssh/id_rsa 貌似在作用域 |
对策三板:
git push 的 allow 规则匹配不了 git push --force,因为 argv 长度与内容都对不上。;、&&、|、反引号、$())的复合命令不适用任何 allow 规则——它的实际行为不再是单条命令,白名单语义已失效。# perm.py —— 判定骨架(写法示意,以实际工程为准) import shlex from pathlib import Path def decide(tool: str, args: dict, rules) -> str: """返回 'deny' | 'allow' | 'ask'。deny 优先;复合命令不享受 allow。""" if hit(rules.deny, tool, args): return "deny" if tool == "bash": argv = shlex.split(args["command"]) if has_shell_meta(args["command"]) or not argv_allowable(argv, rules): return "ask" # 元字符 / 未枚举程序 → 问人 return "allow" if hit(rules.allow, tool, normalize(args)): return "allow" return "ask" # 默认最小权限
权限判定插在第 4.1 节预留的挂点——dispatch 里 item["fn"](**args) 之前:
verdict = decide(name, args, rules) if verdict == "deny": return err("permission_denied", "策略禁止此操作", next_="合法替代路径见 hint") if verdict == "ask": return request_approval(name, args) or err("permission_denied", "用户未批准") return execute(name, args) # allow:静默执行 + 记日志
注意 deny 的返回用的是 4.3 节的错误信封——拒绝必须是带出路的反馈(给合法替代),否则模型会开始寻找绕过(换个等价命令再试一次),那是比拒绝更糟的局面。
规则判定了已知的世界,但 ask 这个出口里装着整个未知——什么时候真的该打断人?打断的成本怎么算?下一节:审批流设计。