从零开始的 Agent 开发:灵活 or 可控?
0x00 前言
上一篇把最小的 Agent Loop 跑了起来,也加了一个简单的 Tool Call。到这一步,Agent 已经不只是生成文本了,它可以读文件、跑命令、改代码。
能力一多,问题也跟着来了:LLM 生成的是概率最高的下一段文本,但工具调用会真的影响本地环境,也即是“副作用”。fs__read 没什么问题,fs__write 就要小心乱改了,shell__exec 更是可能直接爆炸。
所以这一篇写 letcode 的工具权限系统。要回答的问题只有一个:哪些工具能自动执行,哪些工具要问用户,哪些工具应该直接拒绝?
0x01 为什么
最直接的做法是删掉危险工具,比如 shell。安全确实是安全了,那么代价呢?代价就是牺牲了灵活性。shell 的通用性是无法替代的,直接删掉终究不太现实。
那么,全部变成 Tool 呢?项目里会有各种命令,cargo check、npm test、git status、自定义脚本、临时诊断命令等。如果给每种场景都包一层专用 Tool,最后只会变成另一个复杂系统,而且仍然覆盖不全。
所以 shell 不能简单禁掉,只能管起来。
借鉴 opencode 的实现思想,把工具调用当成一个权限裁决问题。LLM 可以提出它想做什么,但执行前要经过一层判断。判断结果只有三种:Allow、Ask、Deny。
0x02 实现
letcode 里,一个 Tool Call 大致会走下面这条链路:
graph TD
A["LLM 发起 Tool Call"] --> B["解析 JSON 参数"]
B --> C["判断 Tool 是否在当前 scope 内"]
C --> D["根据工具名/工具实现判断权限类别"]
D --> E["结合当前 turn 的 directive 做限制"]
E --> F{"PermissionPolicy 做 Allow/Ask/Deny 裁决"}
F -->|Allow| G["执行工具"]
F -->|Ask| H{"向用户申请授权"}
H -->|同意| G
H -->|拒绝| I["执行失败"]
F -->|Deny| I
G --> J["记录 ToolResult、ToolExecutionSummary 等"]
I --> J
J --> K["记录 History、生成 Evidence"]
Tool 本身是一层抽象。每个 Tool 需要声明这些东西:
- name
- description
- JSON schema 参数
- strict 标记
- execute 实现
- permission class
这些 Tool 会注册到 ToolRegistry。请求发给模型时,也只会把 registry 里的工具暴露出去。模型不能凭空编一个本地不存在的工具名来调用。
并且“存在”并不意味着“一定能执行”。真正执行前,还要看它的 permission_class、当前的 PermissionPolicy,以及这一轮对话里有没有额外的 ExecutionDirective。
Permission Class
第一层是给工具分权限类别。
- Read: 读操作,
fs__read、search__rg、git__status - Preview: 预览或流程控制,不直接修改工作区,
workflow__todos、子代理调研工具 - Write: 写入操作,
fs__write、edit__apply_patch - Command: Shell 命令,
shell__exec - Unknown: 未知工具,保守处理
它的作用是先把大方向分出来:读操作通常安全,写操作要谨慎,命令执行单独处理,未知工具默认不信。
Permission Mode
第二层是全局权限模式。
Safe 模式最保守,所有操作要授权(Ask),危险命令直接拒(Deny)。
Default 模式(默认)平衡安全和效率:
- Read/Preview 放行
- Write/Unknown 要授权
- Command 看命令风险
Solo 模式更激进。除了明显危险的命令,其他基本放行。它更适合 Agent 自主完成任务的场景,All in!
Command Risk Classification
shell__exec 不能只按工具名判断。git status 和 rm -rf . 都是 shell 命令,但风险完全不同。
所以 letcode 会继续对命令文本做一次分级:
1 | |
目前的实现主要靠字符串模式匹配:
- 破坏性操作:
rm -rf、mkfs、dd if= - shell 控制语法:管道、重定向、命令链
- 网络执行:
curl、wget、| sh - 权限提升:
sudo、shutdown
没有做复杂的 shell parser。原因很简单,真的要完整理解 shell 语义,坑多到根本爬不出来。
现阶段只做了简单的一层拦截:明显只读的放行,明显危险的拒绝,中间地带交给用户。
比如 git status 是 ReadOnly,cargo check 是 LowRisk,git commit 会 Ask,rm -rf / 直接 Deny。
Execution Directive
第三层是临时指令,也就是 ExecutionDirective。它不改变全局模式,只限制当前这一轮或者某个子代理的行为。
- ReadOnly: 只读,常用于探索型子代理
- PlanOnly: 只规划,禁止写入
- AnalyzeOnly: 只分析
- DoNotEdit: 不编辑文件
Directive 的优先级比 Permission Mode 高。即使现在是 Solo 模式,只要当前 directive 是 ReadOnly,写文件和非只读命令都会被拦掉。
这个是为多代理考虑的,也许以后会用得到。
Tool Scope
最后还有一层 Tool Scope,也是给子代理用,目前暂定如此。
- FullAccess: 全部工具
- ReadOnlyExplorer: 只读工具,包括
fs__read、search__rg、git__status等
0x03 设计思考
现在这套流程可以概括成:
Tool Scope → Execution Directive → Permission Mode → Command Risk
每层都有自己的事情要做:
- Scope 管工具可见性,决定子代理能看到哪些工具
- Directive 管临时约束,比如探索模式强制只读
- Mode 管默认策略,平衡安全和效率
- Risk 管具体命令,识别
rm -rf这类危险操作
这里更多是工程取舍(屎山堆积),灵活性跟确定性始终是冲突的。
如果所有工具都 Ask,Agent 每走一步都要停下来问,体验很差。如果所有工具都 Allow,本地环境就要被模型玩弄于股掌之间。
世间安得双全法?