Coding Agent 权限模块设计:分层短路决策与 HITL 人工确认

在 Coding Agent 中,模型生成了工具调用,并不意味着系统应该立刻执行。一次普通调用可能只是读取文件,也可能修改代码、运行 Shell 命令,甚至访问项目目录之外的路径。如果所有操作都直接交给工具执行,模型的一次误判就可能变成真实的外部副作用。
我在项目中增加了一层独立的权限决策模块,将它放在工具查找和 tool.execute() 之间。它不负责执行具体操作,只回答一个问题:这次工具调用应该放行、拒绝,还是交给用户确认?
最终,所有权限结果被统一成 allow、deny、ask 三种状态,并由 Agent 决定下一步动作。
一、权限模块在工具执行链中的位置
一次工具调用进入 Agent 后,首先会根据工具名从注册表中取得具体工具,并检查它在当前模式下是否启用。通过这两项检查后,Agent 才会调用 PermissionChecker.check()。
模型生成工具调用
↓
查找工具并检查启用状态
↓
PermissionChecker.check(tool, arguments)
↓
allow / deny / ask
↓
参数校验与 tool.execute()
权限检查的输入是具体 Tool 对象和本次调用参数,输出是一个 Decision:
@dataclass
class Decision:
effect: Literal["allow", "deny", "ask"]
reason: str
其中,allow 会自然进入参数校验与执行;deny 会立即生成权限错误结果;ask 则暂停当前工具调用,等待用户选择。
把决策和执行分开后,工具只需要实现自己的业务能力,权限策略则集中在一个入口维护。新增工具时,只要声明工具类别并提供可提取的关键参数,就可以复用同一套权限链。
二、为什么采用分层短路决策
权限判断不是简单地查一张白名单。不同风险需要不同机制处理:危险命令适合前置拦截,文件操作需要检查真实路径,用户偏好则适合通过规则和模式配置。
因此,PermissionChecker 按照固定顺序逐层判断。前一层一旦返回明确结果,后面的层就不再执行。

当前决策顺序是:
Plan 模式例外
↓
安全只读命令
↓
危险命令检测
↓
文件路径沙箱
↓
权限规则
↓
权限模式兜底
↓
HITL 用户确认
这种顺序体现了一个原则:越接近硬安全边界的判断越靠前,越灵活的用户配置越靠后。
例如,危险命令一旦命中就直接返回 deny,后面的显式允许规则和宽松模式都无法覆盖它;文件路径越界同样会在进入规则引擎前被拒绝。反过来,普通写文件如果没有命中硬限制和配置规则,才会根据当前权限模式决定自动放行还是询问用户。
三、硬安全层如何拦截危险操作
1. 安全命令与危险命令
命令工具的能力范围很大:同一个工具既能执行 git status,也能执行删除文件或启动外部进程的命令。因此,我没有让所有命令都直接进入人工确认,而是先做两次判断。
第一步是安全命令检查。命令必须完整命中安全集合,或者以“安全命令 + 空格参数”的形式出现,同时不能包含管道、命令连接、重定向、命令替换等元字符,才能自动放行。
第二步是危险模式检测。系统会使用预编译正则识别格式化磁盘、覆盖设备、递归删除根目录、管道执行远程脚本等高风险行为。命中后立即返回具体拒绝原因。
这里采用的是保守策略:未被识别为安全,不代表一定危险,而是继续进入后续规则或人工确认;只有明确命中危险模式时才直接拒绝。
2. 文件路径沙箱
对于读写文件类工具,权限判断不能只检查字符串是否以项目目录开头。例如,..、符号链接和 Windows junction 都可能让表面上位于项目内的路径最终指向外部目录。
PathSandbox 会先将相对路径基于项目根目录展开,再解析真实路径,最后通过目录组件级的 relative_to() 判断目标是否位于允许根目录中。默认允许范围包括项目根目录和系统临时目录,也可以额外配置其他目录。
如果目标文件尚不存在,系统会向上寻找最近的已存在祖先,解析祖先的真实位置,再拼回未创建的路径部分。这样既允许创建新文件,也能识别通过符号链接或 junction 逃逸项目目录的情况。
需要明确的是,这里的沙箱属于应用层路径校验,不是操作系统级进程隔离。 它能限制文件工具访问哪些路径,但不能约束 Shell 子进程实际执行的系统调用。
四、规则引擎与权限模式如何提供灵活性
硬安全层解决“绝对不能做什么”,规则和模式则解决“在当前项目里默认允许什么”。
1. 三层权限规则
权限规则采用 ToolName(pattern) 语法,同时匹配工具名称和从参数中提取的内容:
Bash(git push *)
WriteFile(docs/*)
规则分为用户级、项目级和本地级三层。系统依次扫描这三层,先匹配的层直接返回;同一层内部倒序扫描,因此最后定义的匹配规则优先。
这种设计既能配置跨项目的个人偏好,也能提供项目共享规则,并把只对当前机器生效的选择保存到本地文件中。用户在确认框中选择“始终允许”时,系统会从当前工具参数提取内容,生成一条带通配符的本地 allow 规则。下一次相似调用到来时,规则引擎会重新加载文件并自动放行。
2. 权限模式矩阵
当没有规则命中时,系统根据“权限模式 × 工具类别”得到兜底结果。工具类别被统一为 read、write、command:
| 模式 | read | write | command |
|---|---|---|---|
default |
allow | ask | ask |
acceptEdits |
allow | allow | ask |
custom |
ask | ask | ask |
bypassPermissions |
allow | allow | allow |
模式矩阵只负责兜底。即使选择宽松模式,也不会绕过前面的危险命令、路径沙箱和显式拒绝规则。
Plan 模式还有一层更早的例外:规划所需的系统工具以及当前计划文件的写入会被提前放行。其他操作并不会被 Plan 模式直接拒绝,而是继续进入后续权限链。
五、ask 如何完成 HITL 挂起与恢复
如果前面的层都没有给出 allow 或 deny,PermissionChecker 最终返回 ask。Agent 随后创建一个 asyncio.Future,将工具名、操作描述和 Future 包装成 PermissionRequest 交给 UI,再在 await future 处暂停当前工具协程。

PermissionChecker 返回 ask
↓
Agent 创建 Future 并发送 PermissionRequest
↓
当前工具协程 await Future
↓
用户在 UI 中选择
↓
UI 通过 future.set_result() 写回结果
↓
Agent 恢复并继续执行或返回拒绝
用户有三种选择:
ALLOW:只允许当前调用,然后继续参数校验和执行。DENY:立即返回权限错误,不执行工具。ALLOW_ALWAYS:先写入本地允许规则,再继续当前调用。
Future 的作用不是阻塞整个程序,而是只挂起当前工具执行协程。UI 仍然运行,用户完成选择后,Agent 从原来的等待点恢复。
对于无法展示确认界面的非交互 Agent,策略会更保守:如果权限结果是 ask,默认返回拒绝;只有明确使用无需询问的模式时才自动继续。这样可以避免后台任务因为没有用户界面而静默执行高风险操作。
六、验证结果与设计边界
权限系统的难点不只是每一层是否有效,更在于层与层之间的优先级是否稳定。为此,我使用回归用例覆盖了模式矩阵、危险命令、路径逃逸、规则优先级和编辑安全等场景。
当前回归脚本共执行 39 个场景,39 个全部通过:安全操作放行率、危险命令拦截率、路径逃逸拦截率和整体权限契约通过率均为 100%。这些测试验证的是应用层决策逻辑,并不代表系统已经具备内核级隔离能力。
整个权限模块最终形成了三类互补机制:
- 危险命令和路径沙箱负责不可绕过的硬安全边界。
- 规则引擎和权限模式负责可配置的默认策略。
- HITL 负责处理系统无法自动确定的操作。
权限模块的本质不是弹出一个确认框,而是在工具真正产生副作用之前,建立一条顺序明确、能够解释并且可以验证的决策链。
更多推荐



所有评论(0)