沙箱与安全
一句话版:DSH 的安全哲学是"默认不信任,故障时拒绝放行":命令在沙箱里被包装(
ctx.sandbox.confine),进程及其所有子进程都在限制下运行;没有可用后端就抛SANDBOX_UNAVAILABLE,绝不裸跑。
这一篇讲清楚 DSH 的进程级边界:它能限制什么、不能限制什么、失败时怎么表现,以及和权限预设/批准策略怎么配合。
一、能力缝:definition 与实现分离
@deepseek-ai/dsh-sandbox 是沙箱的能力缝(Service Definition),只拥有 ctx.sandbox 服务契约 + 共享的 confinement 词汇,依赖只有 cordis(+ error 基类),绝不依赖任何后端。这是 [capability-seam] 拆分的一例:定义和实现对任何消费者透明解耦。
定义层 dsh-sandbox ctx.sandbox.confine(...) + 词汇(SandboxMode/Policy/…)
实现层 dsh-sandbox-local Linux bwrap/Landlock · macOS Seatbelt · Windows ACL
消费者 dsh-bash-sandbox 包装 ['bash','-c',command]
换沙箱实现 = 换 provider,消费方(bash 工具等)代码不变。
二、核心约定:confine
contract 一句话:
ctx.sandbox.confine(argv, policy)返回应当取代你原 argv 的 argv:被包装,使进程及它派生的所有进程都在限制下运行:加上所选后端的 enforcement 完整性、denial 方言(denialSignatures)和结构化 runner 失败证据(runnerFailureRules);没有可用后端时抛异常,绝不把 argv 原样传过跑不受限。
const confined = ctx.sandbox.confine(['bash', '-c', command], policy)
runArgv(spec, confined.argv) // 真实消费者 dsh-bash-sandbox 这样用
ConfinedArgv 的形状:
{
argv: string[], // 应 spwan 的 argv(被包装)
enforcement: SandboxEnforcement, // full | partial
denialSignatures: ..., // 沙箱"拒绝"的 stderr 方言
runnerFailureRules: ..., // "runner/命令失败"的证据规则
}
关键:返回的是对象,不是 argv 数组本身:消费方取 .argv,并可用 denialSignatures / runnerFailureRules 区分两类失败:
- 沙箱拒绝:stderr 方言(EROFS / EACCES / EPERM 等)
- runner / 命令失败:exit code + stderr 规则
三、限制词汇
| 类型 | 取值/含义 |
|---|---|
SandboxMode | read-only / workspace-write / danger-full-access(仅文件效应) |
SandboxEnforcement | full / partial(按内核 ABI;Windows ACL 与较旧 Landlock ABI 为 partial) |
SandboxPolicy | 受限子集(confined) |
SandboxExecutionPolicy | 每次调用的完整模式 + 工作区根目录 |
ctx.sandboxPolicy | 每次调用解析 mode+workspaceRoot 的归属者(默认 read-only,fail-safe) |
| 错误 | SANDBOX_UNAVAILABLE(无法执行所请求模式) |
SandboxMode只覆盖文件效应:词汇里没有网络、进程、syscall、设备、凭据限制。这是它的安全边界,也是它不能做的事。
四、后端实现与前提
| 平台 | 后端 | 前提 |
|---|---|---|
| Linux | bwrap(bubblewrap)或 Landlock launcher | 装 bubblewrap 或跑 Landlock-enforcing 内核 |
| macOS | sandbox-exec(Seatbelt) | 已被 Apple 标记 deprecated,seams 可用 |
| Windows | ACL restricted-token runner | runner 可启动 |
容器 / microVM / 远程执行器 不是这个 seam 的后端:它们是整体替换
ctx.shell/ctx.fs的 provider(作为"环境一致组"),而不是往沙箱加 provider。要进 Docker / 远程机器,是替换能力实现的事,不是给confine加个后端。
"同一世界"限制(源码明确)
沙箱后端共享宿主机的文件系统和内核(bwrap / Landlock / Seatbelt)。workspaceRoot 命名的是文件系统规范的真实宿主目录。工作区身份在词法规范化之前解析:所以含 symlink/.. 的合法 cwd 授权的是 chdir 实际落脚的目录,而不是无关的词法父目录。
五、策略随调用,不随提供方(policy rides the call)
策略属于调用,不属于 provider:
- 两个消费方可同时按不同策略施加限制:bash 用
read-only,同时一个受限子 agent 保持它的状态目录可写 - 获批的升权重试 = 用更宽策略发起的新调用
- 一个 context 一个 provider:要同时组合不同沙箱机制,需要 provider 级阶梯或独立 Cordis context;调用方按调用选策略,不选后端身份
六、策略的家:ctx.sandboxPolicy
@deepseek-ai/dsh-sandbox-policy 是策略解析的唯一归属者:部署默认 SandboxMode + 回退工作区根,外加每个会话的持久模式覆盖与不可变工作区根。每个执行能力每次调用都拿到同一份「mode + root」策略;每个请求前模型收到当前策略,而非单独的能力清单。
为什么要一个共享的家:fs 工具、一次性 bash 命令、终端会话可能以不同组合强制同一套 mode 词汇。若各自解析 mode + workspaceRoot,它们会漂移成"分裂世界"——这正是要避免的。所以每个执行后端消费owner 解析完的整份策略,当前上下文只描述该策略对 DSH 文件沙箱能执行的任何操作意味着什么。
ctx.sandboxPolicy.resolve({ session?, mode? }) // 解析一次完整的每次调用策略
ctx.sandboxPolicy.defaultMode / .workspaceRoot // 部署默认与回退根
setSandboxMode(session, mode) // 会话覆盖的唯一写路径:恰追加一个 sandbox/mode 事件
解析优先级:显式批准的 mode > 会话最后一个 sandbox/mode 事件 > defaultMode;会话不可变 cwd 经文件系统语义规范化后成为 workspaceRoot(规范化先于词法归一,所以 symlink/.. 与进程工作目录解析一致),否则用配置回退。运行时切换就是一个 log-only sandbox/mode 事件,effective = explicit grant ?? fold(events) ?? deployment default,覆盖靠重放跨重启存活,两个会话互不可见对方状态。
挂载:base 默认挂载
sandbox-policy(mode: $DSH_PERMISSION_MODE ?? 'workspace-write',workspaceRoot: process.cwd()),fail-safe 默认read-only。
七、文件系统的栅栏:fs-sandbox
@deepseek-ai/dsh-fs-sandbox 继承 LocalFileSystem 并注册为 ctx.fs:逐字继承全部文本存储机制(解析、stat、读/流、列目录、原子写、读-改-写编辑临界区),只加一个每次调用的 MODE 栅栏在 writeText/editText 上。读永远放行——每个 mode 都允许读。
| mode | 栅栏行为 |
|---|---|
read-only | 拒绝一切改动,结构化 FS_SANDBOX_DENIED |
workspace-write | 仅当目标 canonicalize 后落在可写根内才放行:工作区根 + 平台临时区(/tmp、os.tmpdir())——与 Seatbelt profile 授予的同一集合,都出自同一个 writableRoots 函数,所以 fs 栅栏与 bash runner 不会漂移 |
danger-full-access | 无栅栏直接委托 |
威胁模型明确:这是策略栅栏,不是内核边界——对模型控制的路径在受信代码里做 canonicalize-then-contain;内核级隔离不受信代码仍是 ctx.shell 的活(dsh-bash-sandbox)。残余 TOCTOU(在 containment 复查与 syscall 之间祖先 symlink 被换)靠写前立即重新 canonicalize 收窄。拒绝是结构化 FsError(带有效 mode),不做 stderr 文本推断(不像 bash 的内核拒绝)——因为进程内栅栏确切知道它拒了什么。
挂载:base 默认挂
fs-sandbox(替代fs-local,连同ctx.sandboxPolicy一起就是整套 swap);模型面dsh-tool-fs不动,工具层把会话 mode + cwd 解析成 bash 收到的同一份每次调用策略,两个家族从不限制到不同根。
八、Windows 的 PowerShell 执行器:pwsh-sandbox
@deepseek-ai/dsh-pwsh-sandbox 是 ctx.shell 执行器缝的沙箱消费版 PowerShell 实现:每条命令跑成 pwsh -NoLogo -NoProfile -NonInteractive -Command <command>,经 ctx.sandbox 施加限制,并把选定的 mode、enforcement、denial 事实盖在每次 settle 的结果上。它是 dsh-bash-sandbox 的 pwsh 孪生,调用对调用镜像。
- 限制实质平台中立:Windows 上沙箱缝解析到 ACL restricted-token runner 链,Linux/macOS 上到 bwrap/Landlock/Seatbelt
danger-full-access:命令原样过本地执行器,结果带sandbox: { mode, denied: false }- 受限 mode(
read-only/workspace-write):pwsh argv 被ctx.sandbox.confine()包装;runner 启动被拒 fail-closed 成SANDBOX_UNAVAILABLE(前台 throw、后台runnerFailed事实),被拒的写按选定后端的denialSignatures归类进sandbox.denied - 策略不是它的 config:每次调用从
ctx.sandboxPolicy带过来(工具调用传调用会话的解析策略,直接调用回退部署策略)
挂载:仅 Windows——交付 profile 的
windows.cordis.patch.yml禁用 POSIX 专属bash-sandbox/tool-bash、插入pwsh-sandbox+tool-pwsh,权限面与 POSIX 完全一致。已知限制:Windows 上读不受限(ACL runner 只限制写);read-only仍partial(受限令牌必须保留 Everyone,> $null重定向仍可用)。
九、失败模式(模型看到的)
无法强制执行所请求模式时,返回 SANDBOX_UNAVAILABLE + 精确错误:
sandbox mode "<mode>" is requested but no sandbox backend is usable
on this host; refusing to run the command unconfined.
Install bubblewrap or run a Landlock-enforcing kernel (Linux),
ensure sandbox-exec is usable (macOS),
or ensure the ACL restricted-token runner can start (Windows) —
otherwise switch the consumer to danger-full-access.
执行时的 runner 失败会追加 Runner failure: <detail>。这个错误文本对那个调用保持可见,直到压缩把它们遮蔽。
十、与权限预设、批准策略的关系
这里特别容易混,单独说清三个概念:
| 机制 | 管什么 | 在哪选 |
|---|---|---|
| 权限预设(permission preset) | workspace-write / danger-full-access 等模式档 | settings 的 permission.defaultPreset 或 /permission |
| 批准策略(approval) | ask / never 是否询问用户 | 同 preset |
| 沙箱(sandbox) | 命令实际执行时的文件边界 | 每次调用 ctx.sandbox.confine |
# ~/.dsh/settings.yaml — 选"模式档"
permission:
defaultPreset: danger-full-access
# 但实际命令仍过 ctx.sandbox.confine —— 执行边界是另一回事
重要:权限预设"选择模式",沙箱是"执行"。把 preset 设成 danger-full-access 不等于绕过沙箱:命令仍然过 confine。真正的兜底路径是明确处理 SANDBOX_UNAVAILABLE(比如让消费方在该模式下按需降级),而不是"假装没沙箱"。
十一、安全边界与已知限制(诚实清单)
source README 明确写了的限制,恰是你要记着"它不能保护我什么":
| 限制 | 含义 |
|---|---|
| 文件效应是全部词汇 | 无网络/进程/syscall/设备/凭据限制 |
| 同一世界 | 不隔离主机(容器/microVM/远程需替换 provider) |
| denial 是 stderr 方言 | 没有类型化运行时 denial 通道,消费方需从子进程输出推断 |
| runner 诊断是带内 | exit status + stderr 无法证明哪条是哪个进程写的:受限子进程模仿 runner 可造成诊断误判(但不能绕过 confinement) |
| 一 context 一 provider | 组合不同沙箱需 provider 级阶梯或独立 context |
这些限制的结论:DSH 沙箱是进程级文件效应边界,守护的是"别让 agent 意外越界写/改/删",不是"防御恶意进程或完全隔离"。
十二、验证
# 1. 无后端时命令被拒绝而不是裸跑(观察 SANDBOX_UNAVAILABLE 错误路径)
dsh web
# 2. 看沙箱是否装载/哪个 provider
dsh web --dump-config | grep -iE "sandbox"
# 3. 会话里看工具执行的拒绝(沙箱拒绝是 stderr 方言)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -iE "EROFS|EACCES|EPERM" | head
下一步
- 工具执行:工具声明能力,沙箱执行边界(两者怎么配合)
- 权限:preset 与 ask/never
- Agent 预设与 Persona:scope 组合与安全(restrict 是可见性,非权限边界)