这是《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
}
英文注释的关键点有三个:
confine()要么返回真正受约束的 argv,要么在包装或 runner 执行阶段 fail closed(关闭失败);静默传回原 argv 被明确禁止。- 策略按每一次调用携带,不是某个 Provider 一经注册就永久固定的全局开关。
- 返回值还携带 enforcement(强制执行完整度)事实;调用方不能只看“是否走过 sandbox”这个布尔值。
以 ctx.sandbox 为例看三种角色,而不是把所有 seam 套成同一模板
docs/capability-seams.zh.md:449-460 把系统列为 core、seam、bundle 等不同类别。因此“三角色”是阅读一个可替换 seam 的实用视角,不是“每个服务都必然有一个可互换 Provider”的定理。
| 层次 | ctx.sandbox 中的实际对象 | 不能因此推出什么 |
|---|---|---|
| 服务声明 | SandboxProvider / ctx.sandbox | 不代表它是完整容器或远程执行接口 |
| 本地提供方 | LocalSandboxProvider(sandbox-local) | Linux、macOS、Windows runner 是其内部候选项,不是四个并列 Cordis Provider |
| 直接消费者 | bash-sandbox、terminal-bash | tool-fs 与 subagent 并不因此直接调用 confine() |

图:依据 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-only(packages/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。但 47f9438 的 dsh-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-write | session workspace 与后端定义的临时根可写 | 永远只有一个工作区路径可写,或所有工具能力自动可用 |
danger-full-access | shell / 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'
}
这两段源码说明了两种不同结果,不能混在一起:
- 无可用选择:
confine()直接抛SandboxUnavailableError,不会返回原 argv。 - 已包好 argv 但 runner 未能启动或在命令前失败:消费方依据
runnerFailureRules分类为 sandbox 基础设施失败;命令并未运行。 - runner 正常执行并拒绝了文件效果:这是受限成功的 denial,不是 runner 未启动。
这也是为什么 ConfinedArgv 同时携带 denialSignatures 与 runnerFailureRules。前者是“约束生效并阻止了命令”的 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_DENIED;workspace-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 是什么 | 四个可直接互换的 Provider | sandbox-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-sandbox 与 terminal-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-only;47f9438 的 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 边界表,再决定哪些能力需要单独替换。
