跳到主要内容
路径文档

沙箱与安全

一句话版: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 规则

三、限制词汇

类型取值/含义
SandboxModeread-only / workspace-write / danger-full-access(仅文件效应)
SandboxEnforcementfull / partial(按内核 ABI;Windows ACL 与较旧 Landlock ABI 为 partial)
SandboxPolicy受限子集(confined)
SandboxExecutionPolicy每次调用的完整模式 + 工作区根目录
ctx.sandboxPolicy每次调用解析 mode+workspaceRoot 的归属者(默认 read-only,fail-safe)
错误SANDBOX_UNAVAILABLE(无法执行所请求模式)

SandboxMode 只覆盖文件效应:词汇里没有网络、进程、syscall、设备、凭据限制。这是它的安全边界,也是它不能做的事。

四、后端实现与前提

平台后端前提
Linuxbwrap(bubblewrap)或 Landlock launcher装 bubblewrap 或跑 Landlock-enforcing 内核
macOSsandbox-exec(Seatbelt)已被 Apple 标记 deprecated,seams 可用
WindowsACL restricted-token runnerrunner 可启动

容器 / microVM / 远程执行器 不是这个 seam 的后端:它们是整体替换 ctx.shell/ctx.fs 的 provider(作为"环境一致组"),而不是往沙箱加 provider。要进 Docker / 远程机器,是替换能力实现的事,不是给 confine 加个后端。

"同一世界"限制(源码明确)

沙箱后端共享宿主机的文件系统和内核(bwrap / Landlock / Seatbelt)。workspaceRoot 命名的是文件系统规范的真实宿主目录。工作区身份在词法规范化之前解析:所以含 symlink/.. 的合法 cwd 授权的是 chdir 实际落脚的目录,而不是无关的词法父目录。

五、策略随调用,不随提供方(policy rides the call)

策略属于调用,不属于 provider:

  • 两个消费方可同时按不同策略施加限制:bashread-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 后落在可写根内才放行:工作区根 + 平台临时区(/tmpos.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-sandboxctx.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-onlypartial(受限令牌必须保留 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

下一步