refactor(capabilities): 引入 fs / shell 能力 seam(H1) - #358
Conversation
工具此前各自 `import fs from "node:fs"` / `spawn`,把"这个工具到底在哪儿执行" 写死成十四处调用点里的既成事实。路线图上有三件事被它卡住: - Dispatch 想把执行搬到容器或远程主机,就得给每个工具写第二份实现; - Cloud 多租户只能把 read/write/bash 整个从注册表里删掉(cloudSafeSubset), 因为没有任何办法给它们划界——**按遗漏拒绝是补丁,有界执行世界才是解**; - 测试只能对着真实磁盘跑。 按 dsh 的 seam 模型分三种角色:接口(capabilities/types.ts)、提供方 (local.ts / memory.ts)、消费方(工具)。换提供方 = 一次性换掉所有消费方的 执行世界,工具代码一行不改。 - `ToolContext.caps?: Capabilities` — 可选,未设即本机磁盘与 shell,所以十几处 已有的 ToolContext 构造点全部原样编译、行为零变化。 - 工具一律经 `capsOf(ctx)` 取世界,绝不直接读 `ctx.caps`,本机兜底只有一处。 - `resolvePath` 放进 fs seam 而不是各工具自己 `path.resolve`:这是策略能拒绝 逃逸的**唯一咽喉**,也正是 H2 的沙箱能做成"换提供方"而不是"给七个工具各加 一道检查"的原因,并从结构上保证 fs 与 shell 不会被限制到不同的根目录。 - `shell.run(命令串)` 与 `shell.exec(argv)` 是两个方法而非一个:grep 传的是 模型给的 pattern,把 argv 塞进 shell 串正是注入漏洞的来源。dsh 把这两者拆成 两个 seam,LISA 的体量下一个 seam 两个操作足够,但方法必须分开。 - `LISA_SANDBOX` 包装从 bash 工具移入 localShell.run——命令跑在什么约束下是执行 世界的属性,不是发起工具的属性。此处为原行为搬家,H2 才真正重构它。 迁移的 7 个工具:read / write / edit / apply_patch / ls / grep / bash。 其余工具不动——它们操作的是 LISA 自己的 home,本来就该走 lisaHome()。 测试(13 例): - **防回归断言**:7 个工具的源码不得 import node:fs / node:child_process, 且必须出现 capsOf(ctx)——避免日后有人绕过 seam 而无人发现; - 内存提供方:read/write/edit/ls/apply_patch 五个工具原样跑在 Map 上, 并断言真实磁盘未被触碰; - 有界世界:`../` 与绝对路径逃逸在 resolvePath 处即被拒,读写双向,且拒绝前 没有发生任何 I/O; - 无进程世界:bash 明确报错而不是假装成功。 本机路径实测:exit code / 非零退出 / 超时 SIGTERM / grep 无匹配 / 沙箱开启后 写 cwd 外被 Seatbelt 拒绝,均与重构前一致。全量 1565 通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… caps.shell Review found the "single choke point" framing overclaims: the 7 primitive fs/shell tools route through caps, but the workspace-spawning tools via exec-util (run_checks, compare_agents, redeploy, dispatch_agent, repo/PR/review helpers) still use raw spawn and don't pass through caps.shell. A sandbox provider (H2) swapped in at ctx.caps bounds the primitives but not those. Document the true scope at the seam so H2 and future readers don't build on a false "bound the world in one place" guarantee; routing the exec-util family through the seam is the follow-up that makes it literally true. No behavior change (the 7 migrations are already verified faithful). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review — findings + fix appliedVerified every one of the 7 tool migrations (read / write / edit / apply_patch / ls / grep / bash) against its pre-seam version: no runtime regression. Return shapes, error strings ( MED — fixed (docs)The PR frames the seam as the single choke point ("其余工具…操作的是 LISA 自己的 home"), but that's not accurate: a family of tools spawns subprocesses in the workspace ( Rather than balloon H1's scope, I added a scope note at the seam ( LOW — noted, not changed
|
问题
工具此前各自
import fs from "node:fs"/spawn,把"这个工具到底在哪儿执行"写死成十四处调用点里的既成事实。路线图上有三件事被它卡住:read/write/bash整个从注册表里删掉(cloudSafeSubset),因为没有任何办法给它们划界 —— 按遗漏拒绝是补丁,有界执行世界才是解;做法
按 dsh 的 seam 模型分三种角色:接口(
capabilities/types.ts)、提供方(local.ts/memory.ts)、消费方(工具)。换提供方 = 一次性换掉所有消费方的执行世界,工具代码一行不改。几个刻意的设计选择:
ToolContext.caps可选capsOf(ctx)ctx.caps,本机兜底只有一处,不会有人漏写?? LOCALresolvePath放进 fs seamshell.run(命令串)与shell.exec(argv)分成两个方法LISA_SANDBOX包装移入localShell.run迁移的 7 个工具:
read/write/edit/apply_patch/ls/grep/bash。其余工具不动 —— 它们操作的是 LISA 自己的 home,本来就该走lisaHome()。测试(13 例)
import node:fs/node:child_process,且必须出现capsOf(ctx)。避免日后有人绕过 seam 而无人发现(正则只匹配 import 语句,不会被注释里的字符串糊弄过去)read/write/edit/ls/apply_patch五个工具原样跑在Map上,并断言真实磁盘未被触碰。这不是为了测试更快,而是第二个提供方存在本身就是 seam 为真的证明../与绝对路径逃逸在resolvePath处即被拒,读写双向,且拒绝前没有发生任何 I/Obash明确报错而不是假装成功(静默成功的 stub 会让测试通过而真实路径已坏)本机路径实测
重构后逐条对过,与重构前一致:exit code 0 / 非零退出 3(resolve 而非 throw)/ 超时
signal=SIGTERM/ grep 无匹配 / grep 命中 /ls/ 沙箱开启后写 cwd 外被 Seatbelt 拒绝(Operation not permitted)。全量 1565 通过 / 0 失败,typecheck 干净。
🤖 Generated with Claude Code