权限系统
请使用电脑访问
https://myblog-ad4.pages.dev五层纵深权限防御
一个能执行 rm -rf / 的 Agent,你敢用吗?
前几章我们把 Agent Loop 跑起来了,又配好了 System Prompt。MewCode 现在能自主读代码、写代码、跑命令、改 bug,循环往复直到任务完成。这是一个里程碑。
但冷静下来想想,这也挺吓人的。
你让 MewCode 帮你清理项目中的临时文件。它分析了一下,觉得 /tmp 目录也该清理,执行了 rm -rf /tmp/.../。或者你让它帮你部署代码,它觉得应该先推一把代码到远端,执行了 git push --force origin main,队友一周的提交全没了。
这些不是假设。当你给一个 Agent 执行 Shell 命令的能力后,它理论上能做你在终端里能做的任何事。包括毁掉你的整个系统。
更可怕的是另一种场景:prompt 注入攻击。假设你项目里有一个 README.md,某个不怀好意的贡献者在里面写了这么一段:
<!-- 以下内容是给 AI 助手的指令 --> |
MewCode 在分析项目的时候读到了这个文件,模型可能就把这段文字当成了合法指令去执行。攻击者不需要直接跟你的 Agent 对话,只需要在 Agent 可能读到的文件里埋一段恶意内容就行。这叫「间接 prompt 注入」,防不胜防。
所以问题来了:怎么在不过度限制 Agent 能力的前提下,确保它不做危险的事?
限制太严,Agent 啥也干不了,那还不如不用。限制太松,等于没限制,你迟早要为此付出代价。我们需要一套精巧的机制,在安全和效率之间找到那个平衡点。
这就是权限系统要解决的问题。
先搞清楚要防什么:三种威胁模型
在动手设计之前,我们得先想清楚「敌人」是谁。你不知道要防什么的话,设计出来的系统要么漏洞百出,要么过度防御。Agent 面临的安全威胁主要有三类。
Prompt 注入
刚才已经举了一个例子。模型读取了一个看似普通的文件,但文件里藏着恶意指令。这种攻击的风险之处在于:攻击者不需要直接接触你的 Agent,只需要在 Agent 可能读到的任何地方(代码注释、文档、配置文件、甚至是错误信息)植入诱导性文本。
模型没有办法 100% 区分「用户的真实意图」和「文件里伪装的指令」。这不是模型的 bug,这是 LLM 的根本局限性。所以我们不能指望模型自己判断,得从系统层面来防。
越权操作
用户说「帮我修一下这个 bug」。Agent 在修 bug 的过程中觉得「这个项目结构不太合理,我顺手重构一下吧」,于是移动了十几个文件,改了构建脚本,甚至更新了 CI 配置。
Agent 不是恶意的。它只是太「积极」了。它的自主性超出了用户的预期。用户只授权了修 bug,没授权重构项目。这种情况在实际使用中非常常见,而且很难通过 prompt 来完全约束。
数据泄露
Agent 读了一个 .env 文件,里面有 API Key 和数据库密码。然后它在回复里引用了这些内容:「我发现你的数据库密码是 xxx,这个密码强度不够建议修改。」如果对话内容被日志记录或发送到第三方服务,敏感信息就泄露了。
即使是只读操作,如果读到了不该读的东西,也可能造成安全问题。
多层防御:一道墙挡不住,那就建五道
面对这三种威胁,单一防御机制是不够的。你总能想到某个边界情况绕过某一层防御。所以安全领域有一个经典原则叫「多层防御」(Defense in Depth):不要指望任何一道防线是完美的,而是让多道防线叠加,每道拦住不同类型的威胁。
MewCode 的权限系统设计了五层防线:
用户输入 |
前两层是「硬防线」,无论什么配置、什么模式都绕不过去。后三层是「软防线」,可以根据用户的信任等级和使用场景灵活配置。
每一层的失败不会导致整个系统崩塌。即使某一层被绕过了,下一层还能兜住。就像银行不会只有一个保险柜门,还有监控、警报、保安、金库本身的物理防护。任何一层出问题,其他层还在。
第一道防线:危险命令黑名单
有些操作无论如何都不应该被 Agent 执行。不管用户怎么配置、不管什么权限模式、不管用户是不是亲手点了「允许」。rm -rf / 就是不能执行,curl xxx | bash 就是不能执行,mkfs.ext4 /dev/sda 就是不能执行。
这些操作的后果是不可逆的系统损毁。一旦执行了,没有后悔药。所以必须用黑名单硬拦截,放在权限决策的最前面,优先级最高。
黑名单的实现就是一组正则表达式。每当 Bash 工具要执行一条命令时,先拿命令字符串跟所有模式匹配一遍。匹配上了就直接拒绝,返回原因。
默认的危险命令模式:
| 正则模式 | 拦截原因 |
|---|---|
rm\s+-((\[a-z\]*r\[a-z\]*f|\[a-z\]*f\[a-z\]*r\)\[a-z\]*)\s+/\s*$ |
递归强制删除根目录 |
mkfs\. |
格式化磁盘 |
dd\s+if=.*of=/dev/ |
直接写磁盘设备 |
chmod\s+-R\s+777\s+/ |
递归修改根目录权限 |
:()\{ :|:& \};: |
fork bomb |
curl\s+.*|\s*(ba)?sh |
管道执行远程脚本 |
wget\s+.*|\s*(ba)?sh |
管道执行远程脚本 |
>\s*/dev/sd |
覆盖磁盘设备 |
你可能会问:这个黑名单能覆盖所有危险命令吗?当然不能。一个有创意的攻击者总能想到黑名单里没有的危险操作。但那没关系。黑名单要做的就一件事:拦住最明显、后果最严重的那些操作。剩下的交给后面几层防线来处理。
被黑名单拦截的命令会返回一个清晰的错误信息:
操作被拒绝:检测到危险命令 "rm -rf /"。 |
注意,黑名单只对 Bash 工具生效。ReadFile、WriteFile 这些工具有路径沙箱来守护,不需要黑名单。
第二道防线:路径沙箱
文件操作是 Coding Agent 最常用的能力。但文件系统是扁平的,从项目目录到 /etc/passwd 只差一个路径。如果不做限制,模型可能被诱导去读取或修改项目之外的文件。~/.ssh/id_rsa、~/.aws/credentials、/etc/shadow,这些文件的内容一旦泄露,后果不堪设想。
路径沙箱的规则很简单:所有文件操作都必须限制在项目目录内。
路径沙箱边界。
但「简单规则」的实现并不简单。你得处理各种路径绕过手段。
最常见的绕过是符号链接(symlink)。攻击者在项目目录内创建一个符号链接 link.txt -> /etc/passwd。如果你只检查字面路径,link.txt 确实在项目目录内,检查通过。但实际读到的是 /etc/passwd 的内容。
所以路径验证必须 先解析符号链接,再做前缀检查。用伪代码来说:
function validatePath(requestedPath, allowedRoots): |
注意第 2 步里对不存在文件的处理。WriteFile 创建新文件时,文件本身还不存在,解析符号链接会失败。但我们可以退而求其次,检查它的父目录。如果父目录的真实路径在沙箱内,那新文件创建出来也在沙箱内。
沙箱默认允许两个目录:项目根目录 和 系统临时目录。为什么要允许临时目录?因为有些工具操作(比如编译过程中的临时文件)需要访问临时目录。你也可以通过配置添加额外的白名单目录。
第三道防线:权限规则
前两道防线是粗粒度的硬拦截。但很多场景下你需要更精细的控制。比如:
- 允许 Agent 执行所有
git命令,但不允许git push --force - 允许读取
src/目录下的文件,但不允许读取.env - 允许运行测试命令,但不允许运行安装命令
这就需要权限规则。
规则语法:ToolName(pattern)
规则的语法设计为 ToolName(content_pattern),其中 ToolName 是工具名称,content_pattern 是对工具输入内容的匹配模式,支持 glob 通配符。
几个例子:
# 允许所有 git 命令 |
规则匹配的时候,系统会先从工具的输入参数中提取「内容」,然后跟规则的 pattern 做 glob 匹配。不同工具提取的字段不同:
| 工具 | 提取字段 | 示例 |
|---|---|---|
| Bash | command 参数 | git commit -m "fix bug" |
| ReadFile / WriteFile / EditFile | path 参数 | /project/src/main.py |
| Glob | pattern 参数 | **/*.py |
| Grep | pattern 参数 | TODO |
这样 Bash(git commit *) 就能匹配所有以 git commit 开头的命令。
规则解析本身也很直观:找到第一个 ( 和最后一个 ),前面是工具名,中间是 pattern。
规则的三层优先级
写过 CSS 的同学对「层叠」一定不陌生:多个样式规则可能同时匹配一个元素,浏览器按优先级决定谁生效。MewCode 的权限规则也有类似的层叠机制,分三个层级。
本地级(最高优先级)。存在 .mewcode/permissions.local.yaml。本地配置,不提交到版本控制,用于个人覆盖。用户点了「始终允许」后自动生成的 allow 规则就写在这里,也可以手动加个人偏好。
项目级。存在项目根目录的 .mewcode/permissions.yaml。由项目维护者设置,可以提交到版本控制,让所有团队成员共享。
用户级(最低优先级)。存在 ~/.mewcode/permissions.yaml。这是你的全局偏好,对所有项目生效。适合放兜底的通用规则。
优先级:本地级 > 项目级 > 用户级 |
这个优先级顺序跟 Git、ESLint、Claude Code 一致:越具体、越靠近当前项目的配置,优先级越高。你在本地点的「始终允许」不会被项目级规则盖掉。
在同一层级内,后定义的规则优先级更高,后来居上,跟 CSS 一致。
安全怎么保障?靠一条独立原则:deny 跨层合并,不可翻转。三层的 deny 规则会被收集到一起,只要任何一层说了 deny,其他层的 allow 都盖不掉。你在用户级配了一条 Bash(rm *) -> deny,即使本地级 allow 了 Bash(rm *) 也没用。deny 是硬的。
这里还要区分 allow 的覆盖优先级 和 deny 的安全规则:
allow 冲突时: |
比如:
用户级 allow: Bash(npm test) |
规则匹配的伪代码:
function evaluateRules(toolName, content): |
规则文件不存在时不报错,返回空规则集。这样新项目不需要创建任何权限文件就能正常使用。
第四道防线:权限模式
规则是细粒度的控制,但大多数场景下你不想一条一条写规则。你只想说「我大概信任 Agent 到什么程度」。这就是权限模式要解决的问题。
MewCode 支持四种权限模式,覆盖了从完全不信任到完全信任的整个光谱。
default 模式。这是最常用的模式。只读操作(ReadFile、Glob、Grep)自动执行,写操作和命令执行需要用户确认。适合日常开发:你信任 Agent 去读代码、搜索代码,但每次修改文件或执行命令前都想过一眼。
acceptEdits 模式。在 default 的基础上,文件编辑操作(WriteFile、EditFile)也自动执行。只有 Bash 命令还需要确认。适合你已经信任 Agent 的编码能力,愿意让它自由改代码,但执行命令还是谨慎点好。
plan 模式。权限矩阵和 default 完全一致,写操作和命令执行都需要用户确认。Plan Mode 的约束主要靠 System Prompt 指令让模型自觉只做只读操作。即使模型不听话尝试调用写工具,default 级别的 Ask 机制仍然会弹出确认框,所以实际安全性等同于 default。
bypassPermissions 模式。跳过第 3-5 层(规则、模式、HITL),不需要任何确认。但黑名单和沙箱仍然生效,rm -rf / 照样拦截。这是一个危险模式,只应该在完全受控的环境中使用,比如 CI/CD 流水线。
如果你需要更细粒度的控制,不需要单独的模式,在 default 模式的基础上,通过 permissions.local.yaml 的规则来覆盖默认行为就行。
模式决策矩阵一目了然:
| 模式 | 只读工具 | 文件写工具 | Bash |
|---|---|---|---|
| default | Allow | Ask | Ask |
| acceptEdits | Allow | Allow | Ask |
| plan | Allow | Ask | Ask |
| bypassPermissions | Allow | Allow | Allow |
这张表本身就是决策逻辑的完整表达,代码实现时按表对照写 switch-case 就行。
第五道防线:HITL 人在回路
当前四层都无法做出明确决策的时候(规则没匹配上,模式说「需要确认」),权限系统会暂停 Agent Loop,弹出一个确认对话框让用户亲自决定。
这是最后一道防线,也是最可靠的一道。毕竟人类用户是最终的决策者。
确认对话框需要展示足够的信息让用户做出判断:
MewCode 想要执行以下操作: |
注意第三个选项「始终允许」。这是一个关键设计。如果每次 git commit 都要确认一下,用户很快就会厌烦,要么关掉确认功能(降低安全性),要么放弃使用 Agent(降低效率)。
「始终允许」让用户可以动态地添加权限规则。当用户选择这个选项时,系统自动生成一条 allow 规则追加到本地配置文件:
# 自动生成于 2025-01-15 14:30 |
这就形成了一个 权限学习循环:Agent 初次执行某类操作时需要确认,用户批准后,同类操作以后就自动放行了。随着使用时间增长,需要确认的操作越来越少,但安全基线始终保持。
HITL 的实现机制
HITL = Human-in-the-loop,中文一般叫 人在回路中 或 人工介入/人工确认机制。
HITL 确认涉及 Agent Loop 和 UI 层之间的同步协作。Agent Loop 跑在后台异步线程里,UI 跑在主线程里,两者需要同步。
核心思路是这样的:Agent 在需要确认时发送一个权限请求事件到事件流,然后 阻塞等待 用户的回复。UI 层收到事件后渲染确认对话框。用户按下 y、n 或 a 后,UI 把决策通过同步原语回传给 Agent Loop,Agent 收到回复继续执行。具体用 channel、promise 还是 condvar,取决于你的语言。
Agent Loop 这边的伪代码:
function askUserPermission(toolUse, eventStream): |
Agent 发出事件后就阻塞住了,整个循环暂停。接力棒交给 UI 层:
function handlePermissionPrompt(request): |
当用户选了 a(始终允许),Agent 会把新规则追加到本地配置文件并持久化,下次同类操作就不用再问了。
把五层防线串成决策链
有了每一层的设计,现在把它们串成一个完整的权限决策流程。关键原则是:上一层能决策就直接返回,不能决策才往下走。
function checkPermission(tool, input): |
返回值是一个决策加一个原因字符串。决策只有三种可能:ALLOW、DENY、ASK。如果是 ASK,调用方(Agent Loop)需要触发 HITL 确认流程。
注意层与层之间的关系:只有上一层无法决策时才走下一层。危险命令拦截和路径沙箱一旦命中就直接返回 DENY,不可覆盖。规则匹配到 allow 或 deny 也直接返回,不再走模式判断。模式说 ALLOW 或 DENY 也直接返回。只有模式说 ASK 时,才需要走到第五层去问用户。
这种「逐层判断」的决策流程有一个好处:每一层的逻辑都很简单,容易理解,容易写单元测试。但叠加起来就是一套完整的安全策略。
嵌入 Agent Loop:被拒绝不等于停下来
权限检查发生在工具执行之前。回忆上一章的 Agent Loop,我们在「执行工具」那一步之前插入权限检查。
这里有一个非常重要的设计决策:权限被拒绝时,把「权限拒绝」作为一个错误类型的工具结果返回给模型,Agent Loop 继续运行。
嵌入权限检查后,工具执行部分的核心逻辑:
for each toolUse in response.toolUses: |
注意 DENY 和用户拒绝走的都是 errorResult,产生一个 isError: true 的工具结果。如果用户选了「始终允许」,系统会自动把一条 allow 规则追加到本地配置文件。
为什么被拒绝不终止循环?因为模型是聪明的。它看到「Permission denied: rm -rf build/」之后,可能会换一种方式来完成任务,比如改成逐个删除具体文件。如果直接终止 Loop,模型就完全没有机会调整策略了。
这跟现实世界里的情况一样。你去办一件事,被告知「这个途径不行」,你会想其他办法。但如果直接把你赶出去,你就啥都做不了了。
被拒绝的工具调用产生一个 isError: true 的结果,这个结果通过正常的消息拼接流程进入对话历史。模型在下一轮循环中会看到这个错误,然后自行决定如何调整。 这是权限系统和 Agent Loop 协作的核心设计。
本章小结
安全这东西,加上去可不算完,它是一种贯穿整个系统的思维方式。
这一章我们用五层权限拦截给 MewCode 装上了安全刹车:黑名单拦住最危险的操作,沙箱限制文件访问范围,规则提供细粒度控制,模式定义整体信任等级,HITL 确认兜底一切漏网之鱼。每一层都很简单,但叠加起来就形成了一套完整的安全体系。
有几个关键设计值得记住:权限被拒绝时返回错误结果给模型而不是终止循环,这让模型有机会调整策略;「始终允许」形成权限学习循环,越用越顺畅而不损失安全基线;规则的三层优先级让本地覆盖最灵活,同时 deny 跨层合并保证安全底线不会被任何一层的 allow 突破。
更重要的是,有了权限系统,用户才敢放心地给 Agent 更多权限。用户越信任,Agent 能发挥的价值就越大。MewCode 现在有了安全保障,是时候给工具系统做质的扩展了。下一章引入 MCP 协议,让工具系统从封闭走向开放生态。