这是《DeepSeek Harness 权威指南 》系列的第 7 篇,也是框架线(A 线)的收官篇。源码基线为 deepseek-harness @ 47f9438(v0.1.0-rc.5)。

本文区分三类证据:官方源码与中文文档说明 框架契约;作者 Windows scratch 只说明该工作区、构建和命令下观察到的包装形状;自制图是对源码的解释,不是官方生成图或安全认证。

模型能调用工具,不等于它获得了一个“什么都做不了坏事”的执行环境。dsh 的 ctx.sandbox 解决的是更窄、也更可核验的问题:把同一宿主上即将 spawn 的精确 argv,按每次调用携带的文件效果策略包装起来;无法受限时拒绝,而不是悄悄用原命令执行。

这个限定很重要。它不把网络、读取、进程可见性、凭据、工具注册表或远程隔离一并变成已证明的安全边界。本文先画清 capability seam 的拓扑,再看模式、runner、fail-closed 和提权各自实际承诺什么。

一、先划边界:ctx.sandbox 是同宿主 argv 包装 seam

官方服务声明开篇就限定了它的世界范围(packages/sandbox/sandbox/src/index.ts:1-5):

/**
 * Service Definition for the same-world process-confinement capability seam: wrap exact subprocess argv under a
 * host-path file policy. Containers, microVMs, and remote execution replace the
 * surrounding capability seam instead; this service shares the host kernel and filesystem.
 * @module @deepseek-ai/dsh-sandbox
 */

中文意思是:same-world(同一世界)指子进程仍与宿主共享内核和文件系统。ctx.sandbox 只是在宿主路径文件策略下包装准确的子进程参数数组;容器、microVM、远程执行应替换外围能力 seam,不是塞进这个服务里当普通 runner。

argv 是“程序名加参数”的数组,不是 shell 字符串。例如 shell 消费方可交给它 ['bash', '-c', command];提供方返回的也是可直接 spawn 的新 argv。抽象契约如下(packages/sandbox/sandbox/src/index.ts:152-175):

/**
 * Abstract process-sandbox service. {@link confine} must return enforcing argv
 * or fail closed at wrap or runner-execution time; silent unconfined passthrough
 * is forbidden. Functional probes arbitrate multi-runner chains and may be
 * skipped for a sole candidate, whose own refusal remains the fail-closed end.
 */
export abstract class SandboxProvider extends Service {
  /* v8 ignore next -- abstract service construction is covered through concrete provider packages. */
  constructor(ctx: Context) {
    super(ctx, 'sandbox')
  }

  /**
   * Wrap `argv` so it executes confined under `policy` on this host; the
   * caller spawns the returned argv in place of its own.
   * @param argv - the exact argv the caller is about to spawn (program plus
   *   arguments), NOT a shell string — a shell-shaped consumer passes
   *   `['bash', '-c', command]`.
   * @param policy - the file-effect policy this execution runs under,
   *   carried per call (see {@link SandboxPolicy}).
   * @returns the argv to spawn instead, plus the enforcement completeness
   *   the selected backend achieves for it.
   */
  abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
}

英文注释的关键点有三个:

  1. confine() 要么返回真正受约束的 argv,要么在包装或 runner 执行阶段 fail closed(关闭失败);静默传回原 argv 被明确禁止。
  2. 策略按每一次调用携带,不是某个 Provider 一经注册就永久固定的全局开关。
  3. 返回值还携带 enforcement(强制执行完整度)事实;调用方不能只看“是否走过 sandbox”这个布尔值。

ctx.sandbox 为例看三种角色,而不是把所有 seam 套成同一模板

docs/capability-seams.zh.md:449-460 把系统列为 coreseambundle 等不同类别。因此“三角色”是阅读一个可替换 seam 的实用视角,不是“每个服务都必然有一个可互换 Provider”的定理。

层次ctx.sandbox 中的实际对象不能因此推出什么
服务声明SandboxProvider / ctx.sandbox不代表它是完整容器或远程执行接口
本地提供方LocalSandboxProvidersandbox-localLinux、macOS、Windows runner 是其内部候选项,不是四个并列 Cordis Provider
直接消费者bash-sandboxterminal-bashtool-fssubagent 并不因此直接调用 confine()

ctx.sandbox seam 的准确拓扑:argv 包装、独立 fs 围栏与外层能力 seam

图:依据 47f9438 源码绘制。ctx.sandbox 面向 shell/terminal 的受限执行器包装 argv;fs-sandbox 通过 ctx.sandboxPolicy 做独立路径围栏;容器、microVM、远程执行需要替换更外围的 shell/fs/subprocess seam。图不是官方拓扑原图。

SandboxBashExecutor 的实际接缝只有这一小段(packages/shell/bash-sandbox/src/index.ts:169-179):

  /**
   * Wrap one shell command via the `ctx.sandbox` provider. Provider errors
   * propagate unchanged; the returned argv is handed directly to the local
   * executor's subprocess path.
   * @param command - shell source for the confined inner `bash -c`.
   * @param policy - resolved confined execution policy.
   * @returns the provider's exact argv and settlement-classification facts.
   */
  private confine(command: string, policy: SandboxPolicy): ConfinedArgv {
    return this.ctx.sandbox.confine(['bash', '-c', command], policy)
  }

中文意思是:受限 bash executor 将 ['bash', '-c', command] 交给 ctx.sandbox,再把提供方返回的 argv 交给本地 subprocess 路径。这个关系解释了“替换 shell 的受限 executor 时,上层工具无需认识某个 runner”这一收益;它保证任意 fs、PTY、subagent 或远程实现只改一个配置就能迁移。

二、三种模式是逐调用的文件效果策略

官方类型将模式、工作区根目录和完整度限定得很明确(packages/sandbox/sandbox/src/index.ts:23-72):

/**
 * File-effect policy for confined processes. `read-only` permits only required
 * sinks such as `/dev/null`; `workspace-write` also permits the workspace and a
 * backend-defined temp area; `danger-full-access` bypasses confinement. Network
 * and process visibility are outside this vocabulary.
 */
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'

/** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */
export type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'>

/**
 * The complete file-effect policy resolved for one capability call. The root
 * is carried even under modes that do not consume it so callers can resolve
 * policy once before choosing the enforcement path.
 */
export interface SandboxExecutionPolicy {
  /** The file-effect mode this execution runs under. */
  mode: SandboxMode
  /** Absolute root directory `workspace-write` may write under. */
  workspaceRoot: string
  /**
   * Opaque identity of the calling session (the branded `dsh-session`
   * SessionId). Backends key per-session state off it (e.g. windows-acl gives
   * each live session/workspace pair a random private temp directory and SID,
   * while the workspace SID and standing grant remain per-workspace); absent
   * for agentless calls, which fall back to per-call backend state.
   */
  sessionId?: SessionId
}

/**
 * Enforcement completeness for this host. `partial` means an active backend or
 * older kernel ABI cannot govern every promised file effect; callers requiring
 * an absolute boundary must not treat it as `full`.
 */
export type SandboxEnforcement = 'full' | 'partial'

/**
 * What one confined execution is allowed to touch — carried PER CALL, not
 * fixed on the provider: two consumers may confine under different policies
 * at the same instant (bash under `read-only` while a confined child agent
 * needs its state directory writable), and an approved escalated retry is a
 * new call with a wider policy. Defaulting/resolution is an explicit step at
 * the consumer boundary; the provider treats the policy as fully specified.
 */
export interface SandboxPolicy extends SandboxExecutionPolicy {
  /** The file-effect mode this execution runs under. */
  mode: ConfinedSandboxMode
}

这段英文源码应按字面理解:

  • read-only 约束可用文件沙箱操作的写入效果;某些必要 sink(例如 POSIX 的 /dev/null)属于明确例外,不能简化成“系统里绝对只能读”。
  • workspace-write 允许 session workspace,以及后端定义的临时区域;它不是“唯一可写路径永远只有仓库根目录”的同义词。
  • danger-full-access 绕过这条文件约束路径。网络和进程可见性根本不在这三个字符串的定义范围内。
  • workspaceRoot 是完整策略的一部分;传 { mode: 'workspace-write' } 不是合格的 SandboxPolicy 调用。
  • full / partial 是后端对其承诺的文件效果完整度报告。即使为 full,也不能从该类型推导出网络、凭据或所有进程面都被隔离。

“默认是哪个模式”必须说明组合层级

抽象 SandboxPolicyService 自身把 schema 默认设为 read-onlypackages/sandbox/sandbox-policy/src/index.ts:91-98):

export class SandboxPolicyService extends Service {
  // Inline schema call: the config catalog walks `static Config` statically.
  static Config: z<Config> = z.object({
    mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
    // No schema default: process.cwd() is resolved in the constructor so the
    // stored root is always absolute regardless of how it was supplied.
    workspaceRoot: z.string(),
  })

中文意思是:单独安装这个服务时,schema 的 fail-safe 默认是 read-only。但 47f9438dsh-base 发行组合 又显式覆盖它;这不是所有自定义 deployment 的无条件默认:

    # Every shipped CLI mode starts with the same file-effect boundary.
    # The environment remains an explicit deployment override; otherwise fresh
    # sessions pin workspace-write + ask through the permission service below.
    - id: sandbox
      name: '@deepseek-ai/dsh-sandbox-local'

    - id: sandbox-policy
      name: '@deepseek-ai/dsh-sandbox-policy'
      config:
        mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
        workspaceRoot: !!js process.cwd()

    - id: bash-sandbox
      name: '@deepseek-ai/dsh-bash-sandbox'
      disabled: !!js process.platform === 'win32'
      config:
        timeoutMs: 60000

    - id: pwsh-sandbox
      name: '@deepseek-ai/dsh-pwsh-sandbox'
      disabled: !!js process.platform !== 'win32'

    - id: approval
      name: '@deepseek-ai/dsh-user-approval'
      config:
        policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"

    - id: permission
      name: '@deepseek-ai/dsh-permission-presets'
      config:
        presets:
          read-only:
            sandbox: read-only
            approval: ask
          workspace-write:
            sandbox: workspace-write
            approval: ask
          danger-full-access:
            sandbox: danger-full-access
            approval: never

该 YAML 来自 packages/bundle/base/cordis.patch.yml:166-205。因此更准确的说法是:抽象服务默认 read-only;此提交的 dsh-base 组合默认以 DSH_PERMISSION_MODE ?? 'workspace-write' 配置 sandbox,并把 approval policy 一起组合。 profile、环境变量或其他 bundle 可以改变最终效果。

模式对文件效果的精确承诺不应外推为
read-only可用文件沙箱操作不允许常规文件修改网络、读取、设备、进程面都被完整隔离
workspace-writesession workspace 与后端定义的临时根可写永远只有一个工作区路径可写,或所有工具能力自动可用
danger-full-accessshell / fs consumer 绕过这条文件沙箱约束路径网络、凭据、工具 registry、其他 guard 或部署安全策略全部消失

文件效果策略、完整度报告与一次性提权的边界

图:依据 47f9438 源码绘制。模式只描述文件效果;full / partial 只报告该后端对承诺文件效果的完整度。发行组合、审批应答通道与工具 guard 是不同层的配置/执行机制。图不是官方安全认证。

三、一个本地 Provider 内的 runner chain,不是四个可直接替换的 Provider

LocalSandboxProvider 内部按平台选择和缓存 runner;Linux 是优先链,macOS 与 Windows 是单候选链。以下是源码(packages/sandbox/sandbox-local/src/index.ts:150-187):

/**
 * The runner chain per platform — selection is BY PLATFORM first, probes
 * second: a platform's chain is probed in preference order only when it has
 * MORE than one candidate (probing arbitrates; it does not re-validate a
 * choice that has no alternative). A platform with no chain fails closed at
 * `confine()`. Linux prefers `bwrap` (its mount profile is closest to the
 * mode vocabulary) over the Landlock launcher; darwin has exactly one
 * candidate, selected without any probe.
 */
const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
  linux: ['bwrap', 'landlock'],
  darwin: ['seatbelt'],
  // The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl):
  // a sole candidate, selected without a probe — its execution-time refusal
  // fails closed through its stderr signature (windows-acl-run:) and exit 127.
  win32: ['windows-acl'],
}

/**
 * Enforcement completeness a rung claims when selected WITHOUT a probe (a
 * chain of one). `bwrap` and Seatbelt govern every promised file effect by
 * construction, so the claim is a profile fact; `landlock` is listed for the
 * table's totality but is unreachable unprobed today (the Linux chain has
 * two rungs, so it is only ever selected through its probe, whose report is
 * what distinguishes full from per-ABI-partial — and the launcher additionally
 * self-reports partial enforcement on stderr at every confined run).
 */
const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> = {
  bwrap: 'full',
  landlock: 'full',
  seatbelt: 'full',
  // WRITE_RESTRICTED needs Everyone in both restricting lists for process
  // initialization. An external object that grants Everyone write access
  // therefore remains writable, and NTFS hard links can alias a granted
  // workspace file to a path outside it. The backend enforces the remaining
  // ACL-addressable surface but must not advertise the absolute promise.
  'windows-acl': 'partial',
}

中文解释:

  • Linux 先尝试 bwrap,再尝试 Landlock;多个候选时才通过功能探测裁决。Landlock 的旧 ABI 可能报告 partial,不能只看静态探测结果就断言它总是完整。
  • macOS 的 Seatbelt、Windows 的 windows-acl 在这里都是单候选;单候选不先做“竞争性探测”,但 runner 的启动或执行时拒绝仍要 fail closed。
  • Windows WRITE_RESTRICTED 后端的 partial 不是谦辞:官方注释明确列出 Everyone 写权限和 NTFS hard link alias 两个边界。

Windows ACL package 还明确写出它覆盖与不覆盖的面(packages/sandbox/sandbox-windows-acl/src/index.ts:1-39):它用受限令牌和 capability SID 对目录 DACL 的写入访问做交集检查;读取、网络、进程可见性不受其约束。所以“操作系统参与强制”只说明其覆盖的写入访问面不依赖模型自觉;它不是“模型绝对无法绕过任何安全边界”的证明。

四、fail closed 有两段:包装选择与 runner 执行分类

本地 Provider 先把原 argv 变为 runner argv,再报告该 runner 的 enforcement、拒绝方言和 runner failure 规则(packages/sandbox/sandbox-local/src/index.ts:305-333):

  /**
   * Wrap `argv` in the selected runner's invocation for `policy` — the configured
   * `runnerCommand` when present (the operator's assertion, no probe), else the platform
   * chain's runner speaking its own profile dialect.
   *
   * @param argv - the exact argv the caller is about to spawn.
   * @param policy - the file-effect policy this execution runs under.
   * @returns the wrapped argv plus the selected backend's enforcement completeness, denial
   *   signatures, and structured runner-failure rules; throws the fail-closed
   *   `SANDBOX_UNAVAILABLE` error when the platform has no usable runner.
   */
  confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
    if (this.runnerCommand !== undefined) {
      return {
        argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
        enforcement: 'full',
        denialSignatures: DENIAL_SIGNATURES.runnerCommand,
        runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }],
      }
    }
    const selected = this.selectRunner(policy.mode)
    const runnerArgv = this.runnerArgv(selected.runner, policy)
    return {
      argv: [...runnerArgv, '--', ...argv],
      enforcement: selected.enforcement,
      denialSignatures: DENIAL_SIGNATURES[selected.runner],
      runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner],
    }
  }

confine() 的 fail-closed 选择路径也很直接(packages/sandbox/sandbox-local/src/index.ts:485-510):

  /**
   * Resolve which runner confines commands, once, for the provider's
   * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
   * candidate selected directly, multiple candidates arbitrated by
   * functional probes in chain order. Fail closed when the platform has no
   * chain or no candidate passes — the command never runs.
   */
  private selectRunner(mode: ConfinedSandboxMode): SelectedRunner {
    this.selectedRunner ??= this.chainVerdict()
    if (this.selectedRunner === 'unavailable') throw new SandboxUnavailableError(mode)
    return this.selectedRunner
  }

  /** Walk this platform's chain: sole candidate unprobed, several probed in order, none usable → unavailable. */
  private chainVerdict(): SelectedRunner | 'unavailable' {
    const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? []
    const [first, ...rest] = chain
    if (first === undefined) return 'unavailable'
    // A sole candidate needs no arbitration; its execution-time refusal still fails closed.
    if (rest.length === 0) return { runner: first, enforcement: STATIC_ENFORCEMENT[first] }
    for (const runner of chain) {
      const enforcement = this.probeRunner(runner)
      if (enforcement !== 'unusable') return { runner, enforcement }
    }
    return 'unavailable'
  }

这两段源码说明了两种不同结果,不能混在一起:

  1. 无可用选择confine() 直接抛 SandboxUnavailableError,不会返回原 argv。
  2. 已包好 argv 但 runner 未能启动或在命令前失败:消费方依据 runnerFailureRules 分类为 sandbox 基础设施失败;命令并未运行。
  3. runner 正常执行并拒绝了文件效果:这是受限成功的 denial,不是 runner 未启动。

这也是为什么 ConfinedArgv 同时携带 denialSignaturesrunnerFailureRules。前者是“约束生效并阻止了命令”的 stderr 方言,后者是“runner 在命令执行前失败”的结构化证据;不能只凭非零退出码猜测。

作者 Windows scratch:只观察包装形状与强制选择失败分支

下面的文件不属于 47f9438 的官方源码:

E:/coding/rex-hugo/.tmp-research/dsh-sandbox-demo/sandbox-seam-demo.ts

它在作者 Windows 工作区运行时做了两件事:对带有绝对 workspaceRoot 的策略调用 confine(),以及通过 LocalSandboxProvider.internals.platform 这个测试挂钩强制一个未知平台,走真实的 selectRunner() 无候选分支。它不 spawn 被包装的 pwsh,不验证写入被拒绝,也不模拟生产 runner 故障。

/**
 * A6 能力 seam 实证:ctx.sandbox 包装 argv,LocalSandboxProvider 决定本机 runner
 * 作者本机运行(cwd=E:/coding/deepseek-harness):
 * node --import tsx E:/coding/rex-hugo/.tmp-research/dsh-sandbox-demo/sandbox-seam-demo.ts
 *
 * 注:最后一段使用 LocalSandboxProvider 的测试挂钩强制一个不支持的平台,
 * 用来走真实的选择失败分支;它不是对生产主机 runner 故障的模拟。
 */
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'

function configureAuthorWindowsRunner(provider: LocalSandboxProvider): void {
  if (process.platform !== 'win32') return
  provider.internals.windowsAclRunnerArgs = [
    process.execPath,
    join(process.cwd(), 'packages/sandbox/sandbox-windows-acl/lib/runner.js'),
  ]
}

async function main() {
  const argv = ['pwsh', '-NoProfile', '-Command', 'Write-Output hi']
  const workspaceRoot = process.cwd()
  const app = new Context()
  await app.plugin(LocalSandboxProvider)
  configureAuthorWindowsRunner(app.sandbox as LocalSandboxProvider)

  console.log('=== 原始 argv ===')
  console.log(' ', JSON.stringify(argv))
  console.log('  workspaceRoot:', workspaceRoot)

  for (const mode of ['read-only', 'workspace-write'] as const) {
    const policy = { mode, workspaceRoot } satisfies SandboxPolicy
    console.log(`\n=== confine(mode=${mode}) ===`)
    const confined = app.sandbox.confine(argv, policy)
    console.log('  包装后 argv:', JSON.stringify(confined.argv))
    console.log('  enforcement :', confined.enforcement)
  }

  console.log('\n=== forced unavailable:真实选择失败分支 ===')
  const unavailableApp = new Context()
  await unavailableApp.plugin(LocalSandboxProvider)
  const unavailableProvider = unavailableApp.sandbox as LocalSandboxProvider
  unavailableProvider.internals.platform = 'unsupported-demo-host'
  try {
    unavailableApp.sandbox.confine(argv, { mode: 'read-only', workspaceRoot })
  } catch (error) {
    const err = error as SandboxUnavailableError
    console.log('  错误名:', err.name)
    console.log('  错误码:', (err as unknown as { code: string }).code)
    console.log('  消息  :', err.message)
  } finally {
    await unavailableApp.fiber.dispose()
  }

  await app.fiber.dispose()
}

main().catch((error) => { console.error(error); process.exit(1) })

作者本机 stdout 如下;路径、Node 安装位置和 partial 只描述该次观察:

=== 原始 argv ===
  ["pwsh","-NoProfile","-Command","Write-Output hi"]
  workspaceRoot: E:\coding\deepseek-harness

=== confine(mode=read-only) ===
  包装后 argv: ["D:\\Program Files\\nodejs\\node.exe","E:\\coding\\deepseek-harness\\packages\\sandbox\\sandbox-windows-acl\\lib\\runner.js","--workspace","E:\\coding\\deepseek-harness","--temp","C:\\Users\\Administrator\\AppData\\Local\\Temp","--mode","read-only","--","pwsh","-NoProfile","-Command","Write-Output hi"]
  enforcement : partial

=== confine(mode=workspace-write) ===
  包装后 argv: ["D:\\Program Files\\nodejs\\node.exe","E:\\coding\\deepseek-harness\\packages\\sandbox\\sandbox-windows-acl\\lib\\runner.js","--workspace","E:\\coding\\deepseek-harness","--temp","C:\\Users\\Administrator\\AppData\\Local\\Temp","--mode","workspace-write","--","pwsh","-NoProfile","-Command","Write-Output hi"]
  enforcement : partial

=== forced unavailable:真实选择失败分支 ===
  错误名: SandboxUnavailableError
  错误码: SANDBOX_UNAVAILABLE
  消息  : sandbox mode "read-only" 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.

这里最值得验证的是两条边界,而不是把它包装成“Windows 安全实测”:一是 workspaceRoot 已在正确策略中出现;二是未知平台时由真实 Provider 选择分支抛出 SANDBOX_UNAVAILABLE。若要验证“某次真实子进程写入被拒绝”,还必须额外执行 runner、构造写入目标、检查 stderr 分类,并在隔离测试环境处理 ACL / 临时目录副作用。

五、fs-sandbox 是独立路径围栏,不是 confine(argv) 的别名

tool-fs 通过 ctx.fs 工作。fs-sandbox 在可信进程内对模型控制路径做 canonicalize-then-contain(规范化再包含性判断);源码在模块注释里直接划出了它与 kernel boundary 的区别(packages/fs/fs-sandbox/src/index.ts:1-28):

/**
 * `SandboxedFileSystem`: the sandbox-enforcing implementation of the
 * `@deepseek-ai/dsh-fs` Service Definition. It extends `LocalFileSystem` so all
 * text-storage mechanics — resolve, stat, read/stream, list, the atomic
 * write and the read-match-write edit critical section — are the local
 * implementation's, verbatim; this package adds only the per-call POLICY fence
 * on the two mutations. Reads pass through untouched: every mode permits
 * reading.
 *
 * The fence is a policy check in TRUSTED code over a MODEL-CONTROLLED path,
 * NOT a kernel boundary — the operations are the seam's own (open, rename),
 * and only the target path is untrusted, so canonicalize-then-contain is the
 * complete answer to this surface. Kernel-grade isolation of untrusted CODE
 * stays `ctx.shell`'s job (`@deepseek-ai/dsh-bash-sandbox`). This mirrors the
 * `code-runtime` stance: containment, not a security boundary. The residual
 * TOCTOU (an ancestor symlink swapped between the containment re-check and the
 * syscall) is narrowed by re-canonicalizing immediately before delegating and
 * is accepted for this threat model.
 *
 * Per-call policy: `read-only` denies every mutation; `workspace-write` allows
 * a mutation only when the target canonicalizes under the policy's workspace
 * root or a platform temp area (the SAME writable-root set Seatbelt grants,
 * derived from the one `writableRoots` function so bash and fs cannot drift);
 * `danger-full-access` delegates unfenced. A denial throws the structured
 * `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel
 * stderr), because an in-process fence knows exactly what it refused. The
 * escalation retry lives in the tool layer (`@deepseek-ai/dsh-tool-fs`),
 * exactly as bash's does.

中文意思是:这是可信代码在模型可控路径上的 policy fence(策略围栏),不是 kernel boundary(内核边界);它只对该 seam 自己的 open/rename 等操作作路径 containment,保留并承认缩窄后的 TOCTOU 风险。读取直接通过;write/edit 才由逐调用 policy 围栏。真正的写入判断如下(packages/fs/fs-sandbox/src/index.ts:115-148):

  /**
   * Enforce the per-call policy against `target` and return the EXACT target the
   * mutation must use, so the checked identity is the mutated one (no
   * check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
   * re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
   * reflecting a concurrently swapped symlink), requires containment under a
   * writable root, and returns THAT fresh target; `danger-full-access` returns
   * the caller's target unfenced. Throws the structured `FS_SANDBOX_DENIED` on
   * refusal — the tool layer maps it to the model-facing `[sandbox: …]` marker
   * and the escalation hint.
   */
  private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
    const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
    const { mode } = policy
    if (mode === 'danger-full-access') return target
    if (mode === 'read-only') {
      throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
    }
    // workspace-write: containment on the FRESH canonical path (catches a
    // symlink ancestor swapped since the tool resolved this target), and the
    // mutation delegates with THIS fresh target — never the stale one.
    const fresh = await this.resolve(target.displayPath)
    let contained = false
    for (const root of writableRoots(policy)) {
      if (await isPathUnder(fresh.targetKey, root)) {
        contained = true
        break
      }
    }
    if (!contained) {
      throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
    }
    return fresh
  }

中文解释:read-only 的 write/edit 在这里得到结构化 FS_SANDBOX_DENIEDworkspace-write 会重新规范化路径,检查其是否位于 writableRoots(policy) 下,然后将同一个新鲜 target交给写入操作,以缩小检查和写入之间的路径切换风险。它仍是受信任代码的 path fence,不应宣传为与 shell runner 相同的内核级隔离。

ctx.subagents 也不是 ctx.sandbox 的同义词。它有自己的 provider registry;子 agent 创建时传播的是会话策略事件,而不是“subagent 直接调一次 confine()”。源码如下(packages/subagent/subagent/src/child-agent.ts:193-224):

/**
 * Capture the parent's persistent policy overrides for a child session. Only a
 * sandbox override is captured — never deployment defaults or one-shot
 * grants — and the approval policy is pinned to `'never'` regardless of the
 * parent's own policy.
 * @param parent - the delegating parent agent.
 * @returns the sandbox override (or `undefined` without one) and the approval pin.
 */
export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {
  return {
    sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),
    approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never',
  }
}

/**
 * Append the captured delegation policy onto the child's own log as
 * `source: 'delegation'` events inside the unpublished creation window, so the
 * child's effective policy is reconstructable from its log alone. Appends land
 * after any fork seed, so fresh policy wins stale seed state; later child
 * switches still win over these events.
 * @param childSession - the unpublished child's session.
 * @param overrides - the policy captured at delegation.
 */
export function appendDelegatedPolicyOverrides(
  childSession: Session,
  overrides: DelegatedPolicyOverrides,
): void {
  if (overrides.sandboxMode !== undefined) {
    childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' })
  }
  if (overrides.approvalPolicy !== undefined) {
    childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' })
  }
}

英文注释说明:只捕获父会话的持久 mode override,不会传播 deployment default 或 one-shot grant;如果 approval 服务存在,子会话的 approval policy 被固定为 never。这是一条会话策略传播规则,不是对子进程隔离能力的承诺。

六、提权是严格变宽、审批可失败、且只作用于一次调用

沙箱提权的封闭目标集与严格顺序来自 packages/sandbox/sandbox/src/escalation.ts。先看目标词汇表(:22-41):

/**
 * The strictly-wider table: what a call whose effective mode is the key may
 * escalate TO. Checked at EXECUTION, never baked into a tool schema — the
 * schema's enum is {@link ESCALATION_TARGETS}, because schemas are
 * registry-global while the effective mode is per-call truth.
 */
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
  'read-only': ['workspace-write', 'danger-full-access'],
  'workspace-write': ['danger-full-access'],
}

/**
 * The closed escalation-target vocabulary — every mode a call could ever
 * escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
 * whenever the mounted capability confines: cutting the enum down to the modes
 * wider than the composition's DEFAULT would strand a session whose effective
 * mode sits below it (a `danger-full-access` default would advertise nothing
 * while a narrower-switched session stays confined with no lever).
 */
export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']

中文意思是:read-only 是地板,不是升级目标;真正允许的目标要相对本次调用的 effective mode 严格更宽。schema 的枚举不能自行代替运行时判断,因为当前 mode 是逐调用事实。

批准流程本身也不会把“审批”简化成必有用户点击(packages/sandbox/sandbox/src/escalation.ts:143-188):

/**
 * Resolve a sandbox-escalation request BEFORE anything executes: check strict
 * widening against the call's effective mode, then resolve the approval
 * channel, then map every outcome — the ordered fail-closed sequence both
 * enforcing families share. Returns the granted mode to stamp onto exactly
 * this call; throws the distinct verbatim text for every other path (a
 * non-widening request, a missing approval service, an agent-less execution,
 * a rejection, a cancellation, an unanswerable ask) — the tool registry turns
 * the throw into the call's isError result, and nothing has run. A
 * non-widening request never prompts a human.
 * @param request - the escalation to judge (see {@link EscalationRequest}).
 * @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}).
 * @returns the granted mode, consumed by the one call that asked.
 */
export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {
  const { requestedMode: mode, effectiveMode, justification, subject } = request
  // Strict widening is an EXECUTION check against the call's effective mode —
  // deliberately not a schema constraint (the enum is the closed target
  // vocabulary; the effective mode is per-call truth).
  if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
    throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
  }
  if (approval.approver === undefined) {
    throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
  }
  if (approval.agent === undefined) {
    throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
  }
  // Self-contained for the audit trail: approval/asked stores this reason,
  // and the target mode is part of the grant's identity.
  const outcome = await approval.approver.request({
    agent: approval.agent,
    toolName: approval.toolName,
    callId: approval.callId,
    reason: `escalate sandbox to ${mode}: ${justification}`,
    ...approval.signal ? { signal: approval.signal } : {},
  })
  switch (outcome) {
    // The schema enum already pinned `mode` to the closed target vocabulary;
    // the check above proved it is strictly wider.
    case 'allowed-once': return mode as SandboxMode
    case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"`)
    case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
    case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
    default: return assertNever(outcome, 'EscalationOutcome')
  }
}

这里的 approval 是已配置的应答通道;如果 deployment 把它接到 UI,人才会看到确认提示。它也可能由 ACP 等 bridge 回答。没有 approval service、没有 agent、拒绝、取消或无法应答时,本次调用都不执行。只有 allowed-once 会返回 mode,且官方注释明确该 mode 被消费于提出请求的那一次调用

用户审批服务自身在 never 时不分发请求、直接返回 rejected;无应答 listener 或应答 listener 抛错会归一为 unavailable。实际实现如下(packages/interaction/user-approval/src/index.ts:298-329):

  /**
   * Dispatch the waterfall, contained and raced against the request signal.
   * @param req - the borrowed public request.
   * @param session - the request agent's session used for policy lookup.
   * @returns the normalized closed outcome.
   */
  private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
    const signal = req.signal
    if (signal?.aborted) return 'cancelled'
    // The 'never' policy is decided HERE, before any dispatch: a listener
    // registered with `prepend: true` after this service mounts would sit
    // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
    // documented promise that 'never' rejects deterministically regardless
    // of registration order — only the service's own request path can.
    if (this.effectivePolicy(session) === 'never') return 'rejected'
    // Enter the promise chain BEFORE dispatching: a listener that throws
    // SYNCHRONOUSLY (before its first await) must land in the same rejection
    // path as an async one — `Promise.resolve(call())` would let it escape
    // the containment into the caller.
    const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
      () => this.ctx.waterfall(
        scopeTarget(this, req.agent), 'approval/request', req,
        () => Promise.resolve<ApprovalOutcome>('unavailable'),
      ),
    ).then(
      // Normalize a rogue (non-vocabulary) answerer return to the fail-closed
      // outcome instead of leaking it into callers' closed-union switches.
      outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
      // A throwing answerer must fail the QUESTION closed, not the caller's
      // tool call open — the seam contains its callbacks.
      () => 'unavailable',
    )
    if (signal === undefined) return answer
    return await new Promise<ApprovalOutcome>((resolve) => {
      const onAbort = () => {
        signal.removeEventListener('abort', onAbort)
        resolve('cancelled')
      }
      signal.addEventListener('abort', onAbort, { once: true })
      void answer.then((outcome) => {
        signal.removeEventListener('abort', onAbort)
        // After an abort won the race this resolve is a settled-promise no-op:
        // the late answer is discarded by construction.
        resolve(outcome)
      })
    })
  }

中文解释:never 在任何 waterfall listener 之前就稳定返回 rejected;没有应答者的默认结局是 unavailable;错误或非法应答也被收敛为 unavailable,而不是放开工具调用。这比“全开必须用户点击”更准确:是否有 UI、谁作答、默认策略为何,都属于当前组合与部署配置。

七、把“自主性与安全性”写成分工,而不是证明一个万能结论

这里可以有一个实用分析框架,但不能把它冒充成官方定义的“三轴完全正交安全模型”。在此提交中至少存在三种不同机制:

机制源码中的职责不应误称为
sandboxPolicy每次能力调用解析文件效果模式与 workspace root所有能力的完整安全策略
approval / escalation通过应答通道决定一次更宽的 retry 是否放行必然是用户点击、或永久授权
tool guard在 tool body 前提供单调最终拒绝permission preset 的第三个字段

permission preset 确实会组合 sandbox 与 approval 状态;它并不证明二者和所有 guard 永远互不影响。tool guard 的官方定义也很窄(packages/core/tools/src/index.ts:703-711):

/**
 * A monotonic execution guard evaluated after every `tools/pre-execute`
 * listener and before the tool body. Returning a reason denies the call;
 * returning `undefined` leaves it unchanged. Because guards have no allow
 * result, listener ordering cannot turn a denial back into permission.
 * @param execution - the identity-protected call after extensible pre-execute policy completed.
 * @returns a final denial reason, or `undefined` to leave the call allowed.
 */
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined

中文意思是:guard 位于所有 tools/pre-execute listener 之后、tool body 之前;它只能给出最终拒绝理由,不能给出“重新允许”,所以后续 listener 顺序无法撤销已经出现的拒绝。它是工具流水线中的单调拒绝钩子,不是 permission preset 的第三个字段。

因此更稳妥的工程结论是:模型可以在当前 mode、当前工具组合和当前审批策略允许的范围内自动执行;但每一个范围都要按具体 consumer、runner、后端完整度和 deployment 配置检查。 安全不是一个单独的“已沙箱”标签,自主性也不是“无条件执行”。

八、决策表:选择时问清楚承诺范围

设计问题容易写错的简化答案依据源码的更准确答案
ctx.sandbox 是什么通用容器 / 远程安全层同宿主 argv 的文件效果包装 seam
平台 runner 是什么四个可直接互换的 Providersandbox-local 内部按平台选择的 runner chain
workspace-write 是什么只能写仓库根workspace 加后端定义临时区域的文件效果许可
full 是什么完整系统安全对承诺文件效果的完整度报告
Windows ACL 是什么完全隔离覆盖部分写入访问面的受限令牌后端,报告 partial
fail closed 是什么出错后自动改全开受限路径无法强制时拒绝;全开必须由 policy 显式选择
escalation 是什么点击一次后持续升权严格变宽、应答可失败、allowed-once 仅用于本次调用
fs-sandbox 是什么argv sandbox 的别名可信代码中的路径 containment fence,独立于 ctx.sandbox

九、系列路线:A 线收官,B 线开启

到这里,框架线(A 线)完成:A0 是什么 → A1 架构总览 → A2 插件树 → A3 会话与事件 → A4 工具系统 → A5 LLM 与流式 → A6 capability seam 与文件效果约束。

下一篇开始二开线(B 线):

下一篇:二开快速上手:环境、构建与第一个插件


FAQ

Q:ctx.sandbox 的三角色分别是什么?ctx.sandbox 为例,SandboxProvider 是服务声明,sandbox-local 是本地提供方,bash-sandboxterminal-bash 是直接消费者。它包装同宿主上即将 spawn 的 argv;tool-fs、subagent、容器与远程执行分别走其他 capability seam。

Q:三种 sandbox mode 分别管什么? 它们仅定义文件效果:read-only 拒绝常规写入效果,workspace-write 允许会话工作区与后端定义的临时区域,danger-full-access 绕过这条文件约束路径。读取、网络和进程可见性不在此词汇表内。

Q:默认是 read-only 还是 workspace-write 抽象 SandboxPolicyService 的 schema 默认 read-only47f9438 的 dsh-base bundle 显式使用 DSH_PERMISSION_MODE ?? 'workspace-write'。最终值还受环境变量、profile 和自定义组合影响。

Q:fail closed 是什么意思? 受限模式需要返回 enforcing argv;没有可用 runner、runner 无法启动或在命令前失败时,会以 SANDBOX_UNAVAILABLE 拒绝,不静默改用原 argv。danger-full-access 是调用方明确选择的绕过分支,不是失败回退。

Q:Windows ACL 后端提供完整隔离吗? 不提供。它在自己的写入访问控制面上使用 WRITE_RESTRICTED 令牌,但报告 enforcement: partial;读取、网络、进程可见性、Everyone ACL 与 NTFS hard-link 边界仍需单独评估。

Q:受限执行中需要更多权限怎么办? 请求必须以 sandbox_permissions 和 justification 提出,并且目标严格宽于当前调用 mode。配置的 approval 应答通道返回 allowed-once 才会放行一次重试;拒绝、取消、缺失或无可用应答都不会执行该调用。


互动模块

① 讨论:你部署 agent 时,会把“文件效果边界”“网络边界”“凭据边界”“审批策略”拆开评审,还是用一个“已沙箱”标签概括?

② 自查:你的运行环境里,workspace-write 的实际 workspace root、临时目录、runner 和 enforcement 值分别是什么?先把这四项记录下来,再判断风险面。

③ 转发:如果团队正在给 dsh 组合 shell、fs、approval 或远程执行后端,建议一起阅读本文的 seam 边界表,再决定哪些能力需要单独替换。