feat(sandbox): cross-family file sandbox — one policy home, sandboxed fs provider, fs escalation parity

Extend SandboxMode enforcement from bash to the filesystem tools, the sandbox
RFC's deferred cross-family phase.

- dsh-sandbox-policy (new, ctx.sandboxPolicy): the single home for the
  deployment default mode + workspaceRoot and the per-session override event,
  renamed bash/sandbox-mode -> sandbox/mode and moved here with its fold/setter.
  Decouples the bash seam from dsh-session.
- dsh-fs-sandbox (new): SandboxedFileSystem extends LocalFileSystem and fences
  write/edit by the per-call mode (read-only denies, workspace-write contains to
  the workspace + temp roots via the shared writableRoots, danger passes
  through); reads pass through. Structured FS_SANDBOX_DENIED; in-lock parent
  re-canonicalization. A policy fence in trusted code, not a kernel boundary.
- dsh-sandbox: the shared escalation kit (writableRoots, the strictly-wider
  ladder, denial/hint markers, approveEscalation) both tool families use;
  approveEscalation takes a structural approver so dsh-sandbox gains no
  approval/agent dependency, and both tools stay duplication-free.
- tool-fs: write/edit advertise sandbox_permissions/justification under a
  confining ctx.fs, map FS_SANDBOX_DENIED to the shared [sandbox: ...] marker,
  and resolve the same one-approved-wider retry.
- examples/acp-agent: composes sandbox-policy + fs-sandbox, drops the gating
  that disabled the fs stack under confined modes.

RFC docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md; the old
sandbox RFC's In-process/deferred/FAQ sections updated to shipped fact.
This commit is contained in:
kingwl
2026-07-14 20:05:57 +08:00
parent c3137b5474
commit 2dc62497ce
100 changed files with 2238 additions and 385 deletions

View File

@@ -25,16 +25,15 @@
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-bash-local": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"node-addon-landlock-run": "0.0.0-test.0",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -41,31 +41,23 @@
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { resolve } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
/**
* Plugin config: the local executor's knobs plus the sandbox policy. All
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
* fail-safe default; an example that wants a workspace-writable agent opts in
* explicitly). The runner choice is NOT configured here: which platform
* backend confines the command is the `ctx.sandbox` provider's config.
* Plugin config: the local executor's knobs, verbatim. The sandbox policy
* the default mode and the `workspace-write` boundary root — is NOT here: it
* lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
* home both enforcing families read, so bash and fs can never confine to
* different roots. The runner choice is likewise the `ctx.sandbox` provider's
* config, not this executor's.
*/
export interface Config extends LocalConfig {
/** File-sandbox mode commands run under (default: `read-only`). */
mode?: SandboxMode
/**
* Root directory `workspace-write` mode may write under (default: the
* executor's default working directory — `cwd`, else `process.cwd()`).
*/
workspaceRoot?: string
}
export type Config = LocalConfig
/**
* Quote one string as a single-quoted POSIX shell word (embedded single
@@ -141,24 +133,18 @@ function matchesSignature(exitCode: number | null, stderr: string, signatures: r
* INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
* the whole swap — the tool layer is untouched). Its configured mode is the
* fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
* durable `bash/sandbox-mode` override and stamps the effective mode onto each
* durable `sandbox/mode` override and stamps the effective mode onto each
* request, while an approved escalation may stamp a strictly wider mode for
* one call. The tool's per-agent prompt section states that same effective
* mode, and each run's `result.sandbox` reports what actually executed plus
* enforcement completeness.
*/
export class SandboxBashExecutor extends LocalBashExecutor {
static inject = ['sandbox']
static inject = ['sandbox', 'sandboxPolicy']
// The sandbox-specific fields intersect the local executor's Config as an
// inline schema call: the config catalog walks `static Config` statically.
static override Config: z<Config> = z.intersect([
LocalBashExecutor.Config,
z.object({
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
workspaceRoot: z.string(),
}),
])
// No own Config: the sandbox default (mode + workspaceRoot) moved to
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
// verbatim (the config catalog walks the inherited static).
private readonly mode: SandboxMode
private readonly workspaceRoot: string
@@ -182,12 +168,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
constructor(ctx: Context, config: Config) {
super(ctx, config)
// schemastery (static Config) already filled the defaulted fields — the
// cast records that runtime fact (mirrors LocalBashExecutor's config
// cast). `workspaceRoot` and `cwd` have NO schema default, so their
// fallback chain is real branching.
this.mode = config.mode as SandboxMode
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
// The sandbox default (mode + workspaceRoot) is the one shared policy home
// both enforcing families read; injecting sandboxPolicy guarantees it is
// constructed first. workspaceRoot arrives already resolved absolute.
this.mode = ctx.sandboxPolicy.defaultMode
this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
}
/** The configured default mode — the capability fact the tool layer reads. */

View File

@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
/**
@@ -46,7 +47,8 @@ async function tempDir(base: string): Promise<string> {
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { launcherPath } from 'node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
/**
@@ -45,7 +46,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -15,7 +15,8 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
@@ -39,9 +40,15 @@ const passthrough = (argv: readonly string[]): ConfinedArgv =>
/**
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
* per test) and the executor under test on top of it.
* per test), the shared `ctx.sandboxPolicy` (mode + workspaceRoot), and the
* executor under test on top of them. `mode`/`workspaceRoot` route to the
* policy service; the rest (cwd, graceMs, timeoutMs) to the executor.
*/
async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
async function setup(
config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {},
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
) {
const { mode, workspaceRoot, ...execConfig } = config
const calls: ConfineCall[] = []
class FakeSandboxProvider extends SandboxProvider {
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
@@ -51,7 +58,11 @@ async function setup(config: Config = {}, behavior: (argv: readonly string[], po
}
const ctx = new Context()
await ctx.plugin(FakeSandboxProvider)
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config })
await ctx.plugin(SandboxPolicyService, {
...mode !== undefined ? { mode } : {},
...workspaceRoot !== undefined ? { workspaceRoot } : {},
})
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
const bash = ctx.bash as SandboxBashExecutor
bash.internals = { spillDir }
return { ctx, bash, calls }
@@ -86,14 +97,14 @@ describe('the provider hand-off', () => {
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write' })
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) })
})
it('an explicit workspaceRoot wins over cwd', async () => {
it('an explicit workspaceRoot on the policy wins', async () => {
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
await bash.run(bash.resolve({ command: 'true' }))
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))

View File

@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
/**
@@ -43,7 +44,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
return ctx.bash as SandboxBashExecutor
}

View File

@@ -14,9 +14,6 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
@@ -26,6 +23,9 @@
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../bash/bash"
},