从零搭建 Agent Harness 系列(十九)最小权限:Grant 收窄、工作区沙箱与授权落盘

系列十八收口了阶段七。阶段八要解决的不是取消,而是授权太粗:一次「本会话允许 bash」,等于这个会话里任意命令都自动过;read_file 标成 Safe,../etc/passwd 也会自动执行。

本文对应 go-tiny-claw 四次提交:

1
2
3
4
4604fbf  feat: 将 Grant 匹配从工具名缩小到工作区与参数摘要
908c112 feat: 为文件工具增加工作区路径沙箱
db7b9c6 feat: 拒绝破坏性 bash 命令,不再询问用户
9a9c112 feat: 将会话授权持久化到工作区 Grant 文件

一、原来的 Grant 只认工具名

系列十三的 AllowSession 存的是:

1
2
3
4
g.grants.Save(ctx, Grant{
SessionID: request.SessionID,
ToolName: request.ToolCall.Name,
})

查找同样只看这两项。Grant 里其实已经有 WorkDir,但 Gate 没填,Engine 构造 Request 时也没带工作区。

于是会出现:

1
2
3
4
5
用户允许 bash("go test")

本会话任意 bash 都 Has == true

bash("rm -rf /") 不再询问

最小权限的第一刀不是落盘,而是把匹配键改对。磁盘上如果还按工具名存,持久化只会把错误范围写死。

二、匹配键加上工作区和参数摘要

现在的钥匙是:

1
SessionID + WorkDir + ToolName + ArgumentDigest

参数不能直接当 map key:LLM 可能把 {"a":1,"b":2} 写成 {"b":2,"a":1}DigestArguments 先 Unmarshal 再 Marshal(Go 会对 object 的 key 排序),然后 sha256。空参数当成 {}。非法 JSON 对原始字节做 hash,避免和合法 {} 撞上。

Gate 保存时把四元组写全:

1
2
3
4
5
6
7
8
if decision == AllowSession {
err = g.grants.Save(ctx, Grant{
SessionID: request.SessionID,
WorkDir: request.WorkDir,
ToolName: request.ToolCall.Name,
ArgumentDigest: DigestArguments(request.ToolCall.Arguments),
})
}

Has 用请求现算摘要,不要先取出 Grant 再比。找不到键,说明不是同一条授权。

Engine 构造审批请求时补上 session.WorkDir。测试里如果手动 Save 过期 Grant,也必须带上 ArgumentDigest,否则测到的是「键对不上」,不是「过期删除」。

三、路径沙箱是硬边界

filepath.Join 有两个坑:

1
2
Join("/work", "../etc/passwd")  →  还没清掉 ..
Join("/work", "/etc/passwd") → 丢掉 workDir

read_file 又是 RiskSafe,这两类请求会自动执行。审批解决不了这件事:逃逸必须在执行前被拒绝。

规则放在 internal/sandbox,不依赖 toolsapproval

1
2
3
sandbox

tools / approval

ResolveWithinWorkspace 的步骤是:

  1. 拒绝空路径、空工作区、绝对路径
  2. 对 workDir 和 Join 结果做 Abs + Clean
  3. filepath.Rel 看相对路径是不是 .. 开头

不要用 strings.HasPrefix(resolved, root)/work 会误放行 /work-evil

read_filewrite_fileedit_file 都先走这个函数,失败直接返回,不再问用户。bash 这次没接:cmd.Dir 只是起始目录,命令里仍可以 cd ..

四、破坏性命令由 sandbox 判定,Policy 只做映射

拒绝表属于「什么命令危险」,和路径逃不逃逸是一类规则。Policy 只回答危险了怎么办。

1
2
3
4
5
sandbox.IsDestructiveCommand(command)  ← 只收 string

DefaultPolicy 解析 bash JSON,命中则 PolicyDeny

Gate 把 PolicyDeny 映射成 Deny,不问 Handler

匹配是启发式子串,不是完整 shell 解析。空白压扁、转小写之后查:

1
rm -rf / rm -fr / mkfs / dd of=/dev/ / shutdown / reboot / 叉弹

go test ./... 仍会询问。cmd=rm; $cmd -rf 这种绕过挡不住,以后再收。

Grant 测试里「不同参数再问」不能再用 rm -rf / 当第二条命令,否则现在会直接 Deny,测不到摘要是否收窄。

五、授权落到 .claw/grants.json

匹配键稳定之后才落盘。文件在 {workDir}/.claw/grants.json

FileGrantStoreHas / Save / Revoke 和内存版同一把钥匙。写入先写 .tmpRename;落盘失败把内存里那次改动撤掉。过期条目在 Has 时删除并写回。

工厂不能每个 Runtime New 一份文件 Store:两个 Session 会互相覆盖 JSON。RuntimeFactory 按工作区懒加载一次,SessionID 仍在键里,会话之间不串授权。

单元测试里的 Engine 继续用 MemoryGrantStore。CLI / Channel 才走文件。

六、测试要证明什么

Grant 收窄:

  1. 相同参数复用,Handler 只问一次
  2. 不同参数再问
  3. JSON 键顺序不同视为同一 Grant
  4. 不同 WorkDir 不复用
  5. 过期 Grant 先命中再删掉

路径沙箱:相对路径成功;..、嵌套 ..、绝对路径、空路径、空工作区失败。

命令拒绝:破坏性命令 IsDestructiveCommand == truego test 为 false。Gate 对 rm -rf / 不调用 Handler。

文件存储:重新 NewFileGrantStore 后相同参数仍命中;不同参数不命中;过期失效;没有文件也能创建。

七、阶段八还没做完的

1
2
3
4
审计:谁批的、何时、批了哪次调用
bash 限制在工作区内(cd ..、cat /etc/passwd)
符号链接跟随后仍可能逃出工作区
拒绝表不是完整 shell 解析

路线图里的参数级风险,目前只覆盖了「明显破坏性 bash 直接拒绝」。工作区内 write_file 仍询问,这是产品选择,不是漏做。

总结

阶段八先改匹配,再加硬边界,最后才落盘:

1
2
3
4
5
6
7
Grant 认参数摘要和工作区

文件路径必须落在工作区内

破坏性 bash 由 sandbox 判定,Policy 映射为 Deny

同一工作区共享一份 grants.json

授权持久化之前,如果钥匙还是工具名,磁盘上存的也会是错误范围。下一阶段可以补审计,或把 bash 也关进工作区。