沙箱策略与执行世界
沙箱不是一个开关,而是一个 provider-neutral 的**会话级策略事实**,加上一组
**诚实申报执行能力**的 enforcement provider。
对应实现:src/Tether.Sandbox/(契约)与 src/Tether.Sandbox.Local/(seatbelt 同世界);
E2B 容器世界已按 #271 退役。
三档策略与三种完备度
public enum SandboxPolicy
{
ReadOnly, // 一切可读,只有 /dev/null 这类必需的 sink 可写
WorkspaceWrite, // 写被圈定在 canonical workspace root 与平台临时区内
DangerFullAccess, // 无圈定(只能显式 opt-in)
}
public enum SandboxEnforcement
{
Complete, // provider 拦截每一次操作
Partial, // 有操作逃逸圈定;调用方必须把写视为建议性
None, // 无拦截;受限策略在运行期绝不允许报告这一档
}
Enforcement 的存在本身就是一条纪律:provider 必须诚实申报自己的完备度。一个只实现了部分拦截的 provider 报 Partial,调用方据此降级信任;受限策略下没有任何 provider 被允许报 None。
策略事实与 enforcement 状态不是一回事
ISandboxPolicyState 是 provider-neutral seam:按 exact Session 折叠最后一条
sandbox/mode,没有时才使用 deployment fallback;SetPolicyAsync 追加 canonical
会话事实。因此 preset selection、blank-session refresh、cold replay、CLI projection 与两个
live Session 的隔离都不依赖某个 OS provider。
ISandboxState 则是当前 execution world 的 enforcement snapshot:它额外携带
Enforcement 与 immutable workspace root,并由真实 subprocess/fs provider 消费。
路径规范化(symlink 解析等)发生在文件所在的 enforcing provider 一侧——策略层保留
执行世界的路径拼写,绝不在 Harness 宿主上解析远端的 symlink/..。
策略事实写了 workspace-write 不代表操作系统已经圈定执行;受限模式没有 enforcement
provider 时仍以 SANDBOX_UNAVAILABLE 失败关闭,绝不退化为 unconfined。
permission preset 是 sandbox + approval 的完整 bundle:
| preset | sandbox/mode | approval policy |
|---|---|---|
workspace-write | workspace-write | ask |
danger-full-access | danger-full-access | never |
permission/preset whole-state fact、sandbox/mode、approval/policy 与 permissions
projection 使用同一 per-session authority。实际 sandbox/approval 规则漂移后只能派生
custom,不能伪造 preset id 或 origin;Settings 修改默认值也不会改写已开始或已显式选择的
Session。
发布的 Headless/CLI profile 当前没有组合 confinement provider,因此诚实地把 composition
default 设为 danger-full-access + never;这表示“明确 unconfined 且拒绝原本需要询问的动作”,
不是伪称已有沙箱。若 Settings 或显式 selection 改为 workspace-write,下一次真实 Agent turn
会在 turn/start 前检查 enforcement,并在 provider 缺失时以 SANDBOX_UNAVAILABLE 失败关闭。
direct /permission control command 仍可查看或切回可执行 preset,而不会打开 turn。
enforcement provider 的升级规则
升级规则是单向的:运行时只允许放宽(ReadOnly → WorkspaceWrite → DangerFullAccess),收窄以 SANDBOX_NARROW_DENIED 拒绝;重复请求当前已生效的模式是 no-op 成功(对齐 dsh approveEscalation 的 same-mode 语义——模型在 danger-full-access 已生效时会重复请求它,拒绝只会阻断已授权的工作)。这防止一个 turn 中途悄悄收紧导致已承诺给模型的能力被抽走。
稳定失败码
| 码 | 含义 |
|---|---|
SANDBOX_UNAVAILABLE | 请求了受限策略但没有任何圈定 provider——fail-closed,宁可拒绝也不裸跑 |
SANDBOX_NARROW_DENIED | 升级只能放宽,不能收窄 |
SANDBOX_BAD_WORKSPACE | workspace root 无效 |
SANDBOX_SPLIT_WORLD | 不同能力(fs / shell)被绑到了不一致的执行世界 |
不把路径限制冒充 confinement
dsh 的纪律里有一条:workspace 路径检查不是沙箱。Tether 继承它—— 没有任何原生拦截可用时,provider 以 `SANDBOX_UNAVAILABLE` 失败,而不是退化成"尽量限制在目录里"。
本页后续描述的是现有 enforcement 实现;补齐其它平台的原生 confinement 仍由 #24 负责,permission preset 的完整 bundle 不会把这部分范围归到 Commands issue。
执行世界
同世界(seatbelt)。 SandboxedSubprocessRuntime 在 macOS 上把 argv 包进 /usr/bin/sandbox-exec -p <profile>:profile 由 SeatbeltProfile.Build(policy, workspaceRoot) 生成,子进程与宿主共享同一个文件系统视图,只是系统调用被 seatbelt 拦截。其它平台目前没有同世界实现,受限策略在那里 fail closed。
构建者收窄域(v0.24)。 控制器创建的演化构建者会话登记一份 SessionSandboxScope(收窄读/写根 + 环境覆写):seatbelt profile(SeatbeltProfile.BuildScoped)与进程内 fs 读门(SandboxedFileSystem)按同一份清单兑现。可读根限为项目根(L1/L2 工作区在账本树外的 evolution/workspaces/<profile>/<id>/work、L3 为插件项目目录)、控制器授权的只读参考项目、宿主安装目录、.NET/node/pnpm 工具链子根(dotnet 只放行 host/shared/sdk/packs/… 白名单子目录而非整个安装根,/opt/homebrew 只放行 bin/lib/opt/Cellar/share 而非整树)、系统库与项目内私有 HOME/TMPDIR;Tether home(含账本根)、用户家目录、共享 /tmp 与其他项目一律拒绝,写仅限项目与私有临时区。收窄根的祖先只放行 file-read-metadata——构建者不能枚举父目录内容(ls 拒),stat 级探查不受影响。项目内经符号链接指向收窄域外的读取在 fs 门(canonical target key 前缀)与 seatbelt(realpath 后不在 subpath)两侧都 fail closed。收窄域服务缺席即拒绝建构建者会话;账本里非终态构建者会话在重启/HMR 后重发布时必须能补回收窄域(OnBuilderAgentPublishedAsync),补不上即发布失败;普通会话语义完全不变。
unix-socket 收窄。 spawn-broker 握手目录固定在 <TMPDIR>/tether-spawn/(SeatbeltProfile.SpawnHandshakeSocketRoot()),seatbelt 的 unix-socket 放行只覆盖这个前缀与写根内——共享 /tmp 下其他 socket 在收窄域与影子宿主里都不可 bind/connect,影子侧(ShadowOrchestrator)与构建者侧(BuildScoped)引用同一常量。
可写根只有一处定义。 workspace-write 的含义是”workspace root 加平台临时区”(对齐 dsh writableRoots):SandboxWritableRoots.For 给出 workspace root、宿主 /tmp 与用户临时目录(Path.GetTempPath(),macOS 上即 /var/folders/…/T),三者都按 Fs target key 的同一 realpath 身份规范化并去重(macOS 的 /tmp 就是 /private/tmp)。Seatbelt profile 的 subpath 授权与 fs 写门都从这份清单派生,所以不会出现”write 工具写不了 /tmp、bash 却能写”的不对称;read-only 不授予任何临时区。
容器世界(E2B)——已按 #271 退役。 Tether.Sandbox.E2b 曾提供经 envd HTTP API 的远端 microVM 执行世界(E2bExecutionWorld / E2bClient / E2bFileSystem);因从未被任何 shipped profile 选中、无控制面且宿主机内存无界,已整体移除。远程世界 seam 仍然保留:IFileSystem / ISubprocessRuntime / ISandboxState 按 IWorldBound.WorldId 绑定执行世界,mixed world 在 startup drain 内 fail closed(SANDBOX_SPLIT_WORLD);SSH 世界的接入形状已在 #271 登记为 DEFER。
与工具管线的衔接
沙箱模式影响的不是某个工具,而是整条执行 seam:subprocess runtime、fs provider、shell provider 都消费 ISandboxState。模型侧看到的是普通工具结果或一条稳定码的失败,不需要知道沙箱存在;审计侧则能在会话日志里读到每一次模式变更。