让 agent 在本地跑命令,第一次真出事的人都会记住那个瞬间。我做 AI Chat 的时候, 最怕的不是模型答错,是它答对了一半,然后手比脑子快。
这篇文章讲一件事:一次工具调用从模型输出到真正执行,中间该有哪几道关卡。 代码在 xlbbb-cn/ai-chat,Rust 侧的实现集中在 src-tauri/src/tools/risk.rs。
先看全链路。
模型返回一个 tool call,Rust 侧先做语法门检查,再算风险分,分数映射到七级, 每一级对应一种处置方式,处置结果是放行、弹确认框还是直接拒绝。确认框在前端 以 tool call 分组的形式出现,用户点完,命令才进 process.rs 执行。
风险分是四个维度加权出来的:
| 维度 | 权重 | 看什么 |
|---|---|---|
| 黑名单严重程度 | 0.40 | rm -rf /、破坏系统路径、清空 home 和工作区、改 shell rc |
| 白名单覆盖 | 0.25 | 命令是否命中 55 条只读命令 |
| 语义异常 | 0.20 | 下载后执行、链式管道这类组合 |
| 危险能力 | 0.15 | dd、mkfs、format、rm、重定向、读密钥 |
加权后乘 100,再加上惩罚项,封顶 100。分数落到哪一级按固定阈值切:
RISK_THRESHOLDS: 10, 25, 40, 55, 70, 85 0-10 L6 只读 10-25 L5 一般查询 25-40 L4 敏感读取 40-55 L3 用户数据 55-70 L2 用户软件 70-85 L1 系统配置 85-100 L0 系统风险
处置策略按级分开:L5 和 L6 直接放行,L4 放行但记审计,L3 和 L2 由模型先警告再 请用户确认,L1 和 L0 人工复核,90 分及以上无条件拒绝。用户可以在配置里改 auto_accept_confirm_kinds 决定哪几级免确认,支持 "*" 通配。
设计上有两个取舍值得说。
第一个:分数不是用于精确判断,而是为了把「什么时候必须打扰用户」这个决定 独立出来。如果按命令名白名单判,看得到的是"能不能跑",看不到"跑了会动什么"。 按分数分级以后,同一类命令在不同上下文里的处置可以不一样,比如 ls 永远不问, rm 在任何情况下都要问。
第二个:白名单只对 direct 类型的命令生效。一旦命令带管道或者重定向,覆盖率按 0 算,直接推高总分。这样 curl xxx | bash 不会因为前缀是 curl 就滑过去。
这套东西上线前有个真实的漏洞,代码里现在留着注释:
// Destructive commands (rm/mv/del/...) and redirection are dangerous even // when they do not target a system path --- previously rm -rf ~ scored 0 // here and auto-approved at L4.
rm -rf ~ 当时只拿了 25 分,落在 L4,而 L4 是自动放行的。原因是危险能力那一维 只数了 dd、mkfs、curl | bash 和读密钥,没数 rm 本身,所以一条明确的破坏性命令 在这一维贡献 0。补上 rm、mv 和输出重定向之后,rm -rf *、rm -rf ..、 echo x > ~/.bashrc 一起被兜住。
这个 bug 值得单独讲:它当时通过了所有人工检查,因为看的是等级标签,不是分数。 标签写着 L4,L4 在处置表里是"允许",没人觉得不对。把分数打出来看,25 分里 黑名单维度贡献 0,问题一眼就出来了。
第二个坑是 rm -rf / 的处理。加权分把它算进 L1,L1 的处置是人工复核。人工复核 听起来安全,但如果复核的人是晚上十一点连着点确认对话框的人,L1 和放行没有区别。 现在这条在评分前就被单独匹配,加 30 分惩罚并封顶 100。
回头说边界:这是启发式评分,不是沙箱。它防的是模型犯常见错误,不防有人故意 绕。真要隔离,靠容器、靠虚拟机、靠最小权限账户,靠一张正则表是靠不住的。 应用里能做的只有三件事:把危险动作的代价显性化,让用户在按确认之前看到等级 说明,把每次请求和每次工具调用都留在本地日志里可回查。
技能的边界同理。每个技能是一个目录,根钉在 SKILL.md 上,file_actions 的路径 解析不允许越出这个根。这条能挡住顺手读别人的文件,挡不住符号链接和时间差, 所以真正的隔离仍然要在系统层做。
代码在 github.com/xlbbb-cn/ai... 是纯 Rust 实现,可以单独 抽出来用。风险评分这块还在补规则,有踩过类似坑的欢迎直接提 issue。
