Merge branch 'master' into worktree-windows-runtime

This commit is contained in:
Tianyi Cui
2026-07-20 20:39:13 +08:00
1064 changed files with 22568 additions and 10875 deletions

View File

@@ -1,12 +1,13 @@
# sandbox/ — process-sandbox capability family
The confinement half of the [capability-seam split](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface and platform backends. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages.
The confinement half of the [capability-seam split](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): an abstract provider interface, platform backends, and the shared policy home. Consumers hand `ctx.sandbox` the exact argv they are about to spawn and spawn the returned (wrapped) argv instead; policy (`SandboxPolicy`: mode + workspace root) rides each call, so different consumers confine under different policies at the same instant. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) | `ctx.sandbox` |
| `sandbox/` | Abstract process-sandbox seam (the `SandboxProvider` contract + the mode/enforcement/policy vocabulary) plus the shared ESCALATION kit (`approveEscalation`, the strictly-wider ladder, the denial/hint markers) and the `writableRoots` derivation every enforcement dialect shares | `ctx.sandbox` |
| `sandbox-local/` | Local backends by platform chain: Linux `bwrap` else the `landlock-run` launcher (the npm-distributed [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) family, built and released from its own repository), darwin `sandbox-exec`/Seatbelt — multi-candidate chains functionally probed, sole candidates selected directly, verdict cached, fail-closed | (registers `ctx.sandbox`) |
| `sandbox-policy/` | The policy home: the deployment default (mode + `workspace-write` boundary root) and the per-session `sandbox/mode` override (event + fold + write path). Both enforcing families read it, so bash and fs can never confine to different roots | `ctx.sandboxPolicy` |
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
The seam confines SAME-WORLD subprocesses only (shared filesystem and kernel). Containers, microVMs, and remote executors are NOT backends here — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups; the boundary is recorded in [the sandbox Agent Note](../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]`; see [the acp-agent example's default composition](../../examples/acp-agent/) for the composed leaf). In-process tools (fs/web) cannot be confined by an OS wrapper — their sandbox semantics are policy at their own seams (the sandbox RFC's cross-family phase).
Consumers today: [`bash/bash-sandbox`](../bash/bash-sandbox/) (wraps `['bash', '-c', command]` through `ctx.sandbox`) and [`fs/fs-sandbox`](../fs/fs-sandbox/) (an in-process path fence, not an argv wrapper — reads `ctx.sandboxPolicy` and enforces the shared mode on write/edit). The cross-family boundary is the sandbox Agent Note's [cross-family fs sandbox](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) phase; the shared vocabulary lets both families teach the model one denial marker and one escalation flow.

View File

@@ -4,9 +4,9 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca
The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal.
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.

View File

@@ -4,9 +4,8 @@
* @module @deepseek-ai/dsh-sandbox-local/profiles
*/
import { realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run'
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
/**
@@ -36,31 +35,23 @@ export function landlockProfileArgs(policy: SandboxPolicy): string[] {
return landlockGrantArgs({ readOnly: ['/'], readWrite })
}
/** Resolve a granted root to the canonical path the Seatbelt kernel sees. */
function canonicalPath(path: string): string {
try {
return realpathSync(path)
} catch {
// Missing or unreadable roots stay as spelled; an unresolved root grants
// nothing until it exists, which is the conservative outcome.
return path
}
}
/** Quote one path as an SBPL string literal. */
function sbplString(path: string): string {
return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"`
}
/**
* Build the sandbox-exec arguments and SBPL profile for one policy.
* Build the sandbox-exec arguments and SBPL profile for one policy. The
* writable roots come from the shared {@link writableRoots} helper (canonical,
* deduplicated) so the Seatbelt grant and the in-process fs fence
* (`@deepseek-ai/dsh-fs-sandbox`) can never drift apart.
* @param policy - file-effect policy to express as an SBPL profile.
* @returns sandbox-exec arguments before the trailing separator and command argv.
*/
export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
if (policy.mode === 'workspace-write') {
const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
const roots = writableRoots(policy)
if (roots.length > 0) {
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
}
return ['-p', forms.join(' ')]

View File

@@ -0,0 +1,36 @@
# dsh-sandbox-policy — the sandbox policy home (`ctx.sandboxPolicy`)
The single owner of the deployment's sandbox policy: the file-effect [`SandboxMode`](../sandbox/README.md) a session starts from, the `workspace-write` boundary root, and the per-session `sandbox/mode` override every enforcing capability family reads.
## Why a shared home
Two families enforce the same mode vocabulary: the sandboxed bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem provider (`@deepseek-ai/dsh-fs-sandbox`). If each held its own `mode` + `workspaceRoot` config, the two could drift into a split world — bash confined to one root while fs fences another, exactly what [the sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) warns against. Both inject `ctx.sandboxPolicy` and read the SAME default instead. The [cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md) records the decision.
## Config
- `mode` — the deployment default `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`), validated at load. Default `read-only` (fail-safe).
- `workspaceRoot` — the absolute directory `workspace-write` may write under. Default `process.cwd()`, resolved absolute either way.
## Surface
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default the enforcing implementations read for their resolve fallback and boundary.
- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`). The tool layers apply it to stamp each call, so neither the executor nor the provider depends on session events.
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
## The per-session store
A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = fold(events) ?? the deployment default`, so an override survives restart by replay, two sessions never see each other's state, and there is no external config store. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event. Execution honors the fold in each tool layer, weakest-precedence beneath an escalation grant.
## Model Experience
Indirectly, through `dsh-tool-bash` and `dsh-tool-fs`, which render the effective mode this service holds in their `[sandbox: …]` denial markers and escalation prompts; the `sandbox/mode` event itself never reaches the model.
#### KV Cache effect
No direct invalidation; the named consumers own any request-prefix changes, and the mode is deliberately absent from the prompt.
## Known Limitations and Deferred Work
- **`workspaceRoot` is process-wide and fixed for the service's lifetime** — a per-session workspace root is a deferred phase of the sandbox RFC; this package centralizing the root is its groundwork, not its design.
- **File-effect modes only** — `SandboxMode` governs file effects; network and process policy are outside its vocabulary, so no knob here restricts them.

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-sandbox-policy",
"description": "Sandbox policy home (ctx.sandboxPolicy) for the DeepSeek Harness: the deployment default mode + workspace root and the per-session sandbox/mode override, shared by every enforcing capability family",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,84 @@
/**
* The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the
* deployment's sandbox default — the file-effect {@link SandboxMode} a session
* starts from and the `workspace-write` boundary root — plus the per-session
* override kit (the `sandbox/mode` event, its fold, and its write path, from
* `./session-mode.ts`).
*
* Both enforcing capability families read the SAME policy here: the sandboxed
* bash executor (`@deepseek-ai/dsh-bash-sandbox`) and the sandboxed filesystem
* provider (`@deepseek-ai/dsh-fs-sandbox`) inject `ctx.sandboxPolicy` for the
* default mode and workspace root, so bash and fs can never confine to
* different roots — the split world the sandbox RFC warns about. The default
* lives here rather than on either executor's config precisely because it is
* one fact two families share.
*
* This service holds only the DEFAULT; the per-session fold
* ({@link effectiveSandboxMode}) is a pure function the tool layers apply to
* stamp each call, so neither the executor nor the provider depends on session
* events.
*
* @module @deepseek-ai/dsh-sandbox-policy
*/
import { resolve } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
declare module 'cordis' {
interface Context {
sandboxPolicy: SandboxPolicyService
}
}
/**
* Plugin config: the deployment's sandbox default. All optional — `Config`
* supplies the defaults (`mode: 'read-only'` is the fail-safe default; a
* deployment that wants a workspace-writable agent opts in explicitly). The
* runner choice is NOT here (it is the `ctx.sandbox` provider's config), nor
* is any per-family knob: this is the one shared policy home.
*/
export interface Config {
/** File-sandbox mode a session starts from (default: `read-only`). */
mode?: SandboxMode
/**
* Absolute root directory `workspace-write` may write under (default:
* `process.cwd()`). Both enforcing families fence against this SAME root.
*/
workspaceRoot?: string
}
/**
* The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment
* default mode and workspace root; enforcing implementations read
* {@link defaultMode} and {@link workspaceRoot}, and the tool layers fold each
* session's `sandbox/mode` override with {@link effectiveSandboxMode} on top.
*/
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(),
})
/** The deployment default mode — the fallback beneath a session override. */
readonly defaultMode: SandboxMode
/** The absolute `workspace-write` boundary root both families fence against. */
readonly workspaceRoot: string
constructor(ctx: Context, config: Config) {
super(ctx, 'sandboxPolicy')
// schemastery (static Config) already filled `mode`; the cast records that
// runtime fact. `workspaceRoot` has NO schema default, so its fallback to
// the process cwd is real branching, resolved absolute either way.
this.defaultMode = config.mode as SandboxMode
this.workspaceRoot = resolve(config.workspaceRoot ?? process.cwd())
}
}
export default SandboxPolicyService

View File

@@ -0,0 +1,68 @@
/**
* Per-session sandbox-mode override: the session log as the store. A runtime
* switch (an ACP `session/set_config_option`, a test scenario) is recorded as
* one `sandbox/mode` event on the session it applies to;
* `effective = fold(events) ?? the deployment default`, so an override
* survives restart by replay, two sessions can never see each other's state,
* and there is no external config store. The event is log-only (the
* `approval/*` precedent): the model learns the mode from the boundary
* markers in the enforcing tools, never from the event itself. EXECUTION
* honors the fold in each tool layer — it stamps the effective mode onto the
* per-call policy carrier (a bash request's `sandboxMode`, an fs mutation's
* `sandboxMode`), weakest-precedence beneath an escalation grant.
*
* The override is policy state shared by every enforcing family (bash and
* filesystem alike), so it lives here in the policy package rather than in any
* one capability's seam.
*
* @module dsh-sandbox-policy/session-mode
*/
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* The session's sandbox mode was switched — log-only (like `approval/*`;
* NOT a surface event, carries no `surfaceOp`): durable and replayable,
* never in the model transcript. The LAST such event is the session's
* override ({@link effectiveSandboxMode}); who asked for it is derivable
* from position (an event after the log's last `request/header*` was a
* runtime switch by the user; see the tool layer's narrator).
*/
'sandbox/mode': { mode: SandboxMode }
}
}
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
/**
* The session's sandbox-mode override: the last `sandbox/mode` event in the
* log, or undefined when the session never switched (callers apply the
* deployment default). The pure fold — resume needs no catch-up machinery
* because replaying the log IS the state.
* @param events - session events in log order (other event types are skipped).
* @returns the mode of the last switch event, or undefined without one.
*/
export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined {
for (let index = events.length - 1; index >= 0; index -= 1) {
const event = events[index] as SessionEvent
if (event.type === 'sandbox/mode') return event.data.mode
}
return undefined
}
/**
* THE write path for a session's sandbox-mode override: appends exactly one
* `sandbox/mode` event — the switch IS its event; nothing mutates mode state
* out of band. Takes effect on the session's next confined call (bash or fs)
* — the consumers fold on every read.
* @param session - the session the override belongs to.
* @param mode - the mode every subsequent confined call in this session runs
* under (until the next switch).
*/
export function setSandboxMode(session: Session, mode: SandboxMode): void {
session.append('sandbox/mode', { mode })
}

View File

@@ -0,0 +1,67 @@
/**
* Tests for the sandbox-policy home: the deployment default (mode +
* workspaceRoot) the service exposes, and the per-session `sandbox/mode`
* override kit (fold + write path) both enforcing families read.
*/
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) {
const ctx = new Context()
await ctx.plugin(SandboxPolicyService, config)
return ctx
}
describe('SandboxPolicyService', () => {
it('defaults to read-only under the process cwd', async () => {
const ctx = await mounted()
expect(ctx.sandboxPolicy.defaultMode).toBe('read-only')
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve(process.cwd()))
})
it('carries a configured mode and resolves the workspace root absolute', async () => {
const ctx = await mounted({ mode: 'workspace-write', workspaceRoot: '/ws/../ws/./sub' })
expect(ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
expect(ctx.sandboxPolicy.workspaceRoot).toBe(resolve('/ws/../ws/./sub'))
})
it('rejects a mode outside the closed vocabulary at load', async () => {
const ctx = new Context()
// schemastery rejects the union violation when the plugin loads.
await expect(ctx.plugin(SandboxPolicyService, { mode: 'yolo' as never })).rejects.toThrow()
})
it('unregisters cleanly from a child fiber (HMR safety)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(SandboxPolicyService, {})
expect(ctx.sandboxPolicy).toBeDefined()
await fiber.dispose()
expect(ctx.get('sandboxPolicy')).toBeUndefined()
})
})
describe('the sandbox/mode session kit', () => {
it('SANDBOX_MODES lists every mode for advertisement and validation', () => {
expect(SANDBOX_MODES).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
})
it('effectiveSandboxMode folds to the last switch, or undefined without one', () => {
const session = new Session(SessionId('sess-fold'))
expect(effectiveSandboxMode(session.events)).toBeUndefined()
setSandboxMode(session, 'workspace-write')
setSandboxMode(session, 'read-only')
expect(effectiveSandboxMode(session.events)).toBe('read-only')
})
it('setSandboxMode appends exactly one sandbox/mode event per switch', () => {
const session = new Session(SessionId('sess-write'))
setSandboxMode(session, 'danger-full-access')
const modeEvents = session.events.filter(e => e.type === 'sandbox/mode')
expect(modeEvents).toHaveLength(1)
expect(modeEvents[0]?.data).toEqual({ mode: 'danger-full-access' })
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../sandbox"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -1,12 +1,12 @@
# @deepseek-ai/dsh-sandbox
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxPolicy` (per-CALL policy — mode + workspace root), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxPolicy` (per-CALL policy — mode + workspace root), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined.
Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy.
**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names a real host path. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
**Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names a real host path. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: `bwrap`, else the per-platform Landlock launcher; macOS: `sandbox-exec`/Seatbelt). Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/) (wraps `['bash', '-c', command]`).

View File

@@ -0,0 +1,189 @@
/**
* The escalation vocabulary and choreography shared by every sandbox-enforcing
* tool family (`@deepseek-ai/dsh-tool-bash`, `@deepseek-ai/dsh-tool-fs`): the
* strictly-wider ladder, the argument-pairing validation, the model-facing
* denial/hint markers, and {@link approveEscalation} — the ordered fail-closed
* sequence that resolves a `sandbox_permissions` request through a
* user-approval channel BEFORE anything executes. One home keeps the two
* families' approval ordering and verbatim error texts from drifting apart.
*
* The channel is a minimal STRUCTURAL function shape ({@link EscalationAsk}),
* not the approval service type: the tool layer — which owns the agent, the
* call id, and the tool name — closes over `ctx.approval.request(...)` and
* hands the closure down, so this package never depends on the approval or
* agent packages.
*
* @module dsh-sandbox/escalation
*/
import { assertNever } from '@deepseek-ai/dsh-llm'
import type { SandboxMode } from './index.ts'
/**
* 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']
/**
* Validate the escalation argument pairing a tool schema cannot express:
* `sandbox_permissions` and `justification` travel together — an approval
* prompt without a reason, or a reason driving nothing, is a malformed ask —
* and the justification must be a non-empty sentence.
* @param sandboxPermissions - the raw `sandbox_permissions` argument, if given.
* @param justification - the raw `justification` argument, if given.
*/
export function validateEscalationArgs(sandboxPermissions: string | undefined, justification: string | undefined): void {
if (sandboxPermissions !== undefined && justification === undefined) {
throw new Error('invalid escalation: sandbox_permissions requires a justification')
}
if (justification !== undefined && sandboxPermissions === undefined) {
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
}
if (justification !== undefined && justification.trim().length === 0) {
throw new Error('invalid justification: expected a non-empty sentence')
}
}
/**
* The model-facing denial marker — the one vocabulary both enforcing families
* teach and report, so the model recognizes a policy denial identically
* whether the kernel refused a bash file effect or the filesystem provider's
* fence refused a mutation.
* @param mode - the mode the denied call ran under.
* @returns the marker line, exactly as the model sees it.
*/
export function sandboxDenialMarker(mode: SandboxMode): string {
return `[sandbox: file access denied under ${mode} mode]`
}
/**
* The same-turn escalation hint that rides a denial when the composition
* advertises the escalation fields — the nudge lives at the decision point so
* the sanctioned retry does not depend on the model recalling the tool
* description.
* @param subject - the family's noun for the denied action (`command` for
* bash, `operation` for a filesystem mutation).
* @returns the hint line, exactly as the model sees it.
*/
export function escalationHintMarker(subject: string): string {
return `[sandbox: escalation available — retry this exact ${subject} once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`
}
/**
* The closed outcome vocabulary of one escalation ask — structurally identical
* to the approval seam's `ApprovalOutcome` so an `ApprovalService.request`
* return is assignable without this package importing it.
*/
export type EscalationOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
/**
* The minimal approval-request shape {@link approveEscalation} needs —
* structurally the approval seam's `ApprovalService`, generic over the agent
* type `A` and call-id type `C` so this package resolves escalations through
* `ctx.approval` without importing the approval or agent packages (the tool
* layer infers `A`/`C` as its own `Agent`/`CallId`).
*/
export interface EscalationApprover<A = object, C = string> {
/**
* Ask the human to approve one action, resolving to a closed outcome.
* @param req - the audit-self-contained request (agent, tool, call id, reason, optional signal).
* @returns the human's decision as a closed {@link EscalationOutcome}.
*/
request(req: { agent: A; toolName: string; callId: C; reason: string; signal?: AbortSignal }): Promise<EscalationOutcome>
}
/**
* The approval ingredients an escalating tool hands {@link approveEscalation}:
* the approval requester (`ctx.approval`, or `undefined` when none is
* composed), the calling agent (or `undefined` for an agent-less execution),
* and the call's identity. The tool layer holds all of these; this package
* only judges them.
*/
export interface EscalationApproval<A = object, C = string> {
/** The approval requester (`ctx.approval`), or `undefined` when none is composed. */
approver: EscalationApprover<A, C> | undefined
/** The calling agent, or `undefined` for an agent-less execution (fails closed). */
agent: A | undefined
/** The tool-call id the approval prompt attaches to. */
callId: C
/** The tool name recorded on the approval request. */
toolName: string
/** The tool-execution abort signal the approval request rides, when present. */
signal?: AbortSignal
}
/** One escalation request, as {@link approveEscalation} judges it. */
export interface EscalationRequest {
/** The requested target mode (schema-pinned to {@link ESCALATION_TARGETS} when advertised). */
requestedMode: string
/** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */
justification: string
/** The call's effective mode (session override ?? composition default) the request must strictly widen. */
effectiveMode: SandboxMode
/** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */
subject: string
}
/**
* 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')
}
}

View File

@@ -8,6 +8,17 @@
import { Context, Service } from 'cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
export {
ESCALATION_TARGETS,
WIDER_MODES,
approveEscalation,
escalationHintMarker,
sandboxDenialMarker,
validateEscalationArgs,
} from './escalation.ts'
export type { EscalationApproval, EscalationApprover, EscalationOutcome, EscalationRequest } from './escalation.ts'
export { canonicalPath, writableRoots } from './roots.ts'
/**
* File-effect policy for confined processes. `read-only` permits only required
* sinks such as `/dev/null`; `workspace-write` also permits the workspace and a

View File

@@ -0,0 +1,51 @@
/**
* The writable-root derivation shared by every enforcement dialect that
* expresses a mode as a canonical allow-list: `workspace-write` means "the
* workspace root plus the platform temp areas", and this module is that
* meaning's one home. The Seatbelt profile
* (`@deepseek-ai/dsh-sandbox-local`) and the in-process filesystem fence
* (`@deepseek-ai/dsh-fs-sandbox`) both derive their allow-list here, so "the
* write tool cannot write /tmp but bash can" asymmetries cannot arise between
* them. The bwrap and Landlock dialects keep their own grant spellings (an
* ephemeral `/tmp` mount, launcher-owned flags) — the honest per-runner
* differences recorded in the sandbox RFC — with parity pinned by test.
*
* @module dsh-sandbox/roots
*/
import { realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type { SandboxPolicy } from './index.ts'
/**
* Resolve a granted root to the path the enforcement layer actually compares:
* canonical (symlinks resolved), because both Seatbelt filters and the fs
* fence's containment check match resolved paths — `/tmp` IS `/private/tmp`
* on darwin, and an as-spelled grant would match nothing.
* @param path - the root as configured or platform-reported.
* @returns the canonical path, or the spelling as-is when resolution fails
* (a missing root matches nothing until it exists — the conservative
* outcome; inventing a fallback would grant a path the caller never named).
*/
export function canonicalPath(path: string): string {
try {
return realpathSync(path)
} catch {
// realpathSync failed: the path (or a prefix) is missing or unreadable.
return path
}
}
/**
* The roots one confined execution may WRITE under — the mode's meaning as a
* canonical, deduplicated allow-list. `read-only` allows nothing;
* `workspace-write` allows the policy's workspace root, the host `/tmp`, and
* the per-user platform temp dir (`os.tmpdir()` — the real temp area for
* mkstemp-family tools; omitting it would deny what the mode promises).
* @param policy - the file-effect policy to derive the allow-list from.
* @returns the canonical writable roots; empty exactly under `read-only`.
*/
export function writableRoots(policy: SandboxPolicy): string[] {
if (policy.mode !== 'workspace-write') return []
return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
}

View File

@@ -0,0 +1,111 @@
/**
* Tests for the shared escalation vocabulary and choreography: the strictly-
* wider ladder, the argument-pairing validation, the model-facing markers, and
* {@link approveEscalation}'s ordered fail-closed sequence. Both enforcing tool
* families (`dsh-tool-bash`, `dsh-tool-fs`) delegate here, so the ordering and
* verbatim texts are pinned once, next to the vocabulary that owns them.
*/
import { describe, expect, it } from 'vitest'
import {
ESCALATION_TARGETS,
WIDER_MODES,
approveEscalation,
escalationHintMarker,
sandboxDenialMarker,
validateEscalationArgs,
} from '@deepseek-ai/dsh-sandbox'
import type { EscalationApprover, EscalationOutcome } from '@deepseek-ai/dsh-sandbox'
describe('the strictly-wider ladder', () => {
it('read-only escalates to either wider mode; workspace-write only to full access', () => {
expect(WIDER_MODES['read-only']).toEqual(['workspace-write', 'danger-full-access'])
expect(WIDER_MODES['workspace-write']).toEqual(['danger-full-access'])
expect(WIDER_MODES['danger-full-access']).toBeUndefined()
})
it('the target enum is the closed set every session could escalate TO (read-only is the floor)', () => {
expect(ESCALATION_TARGETS).toEqual(['workspace-write', 'danger-full-access'])
})
})
describe('validateEscalationArgs', () => {
it('accepts neither field, or both with a non-empty justification', () => {
expect(() => { validateEscalationArgs(undefined, undefined) }).not.toThrow()
expect(() => { validateEscalationArgs('workspace-write', 'because the workspace needs it') }).not.toThrow()
})
it('rejects one field without the other, and a blank justification', () => {
expect(() => { validateEscalationArgs('workspace-write', undefined) }).toThrow(/requires a justification/)
expect(() => { validateEscalationArgs(undefined, 'orphan reason') }).toThrow(/only valid together with sandbox_permissions/)
expect(() => { validateEscalationArgs('workspace-write', ' ') }).toThrow(/non-empty sentence/)
})
})
describe('the model-facing markers', () => {
it('the denial marker names the mode', () => {
expect(sandboxDenialMarker('read-only')).toBe('[sandbox: file access denied under read-only mode]')
expect(sandboxDenialMarker('workspace-write')).toBe('[sandbox: file access denied under workspace-write mode]')
})
it('the hint marker names the family subject', () => {
expect(escalationHintMarker('command')).toContain('retry this exact command once with sandbox_permissions')
expect(escalationHintMarker('operation')).toContain('retry this exact operation once with sandbox_permissions')
})
})
describe('approveEscalation', () => {
const req = (over: Partial<Parameters<typeof approveEscalation>[0]> = {}) => ({
requestedMode: 'workspace-write',
justification: 'the user asked to write in the workspace',
effectiveMode: 'read-only' as const,
subject: 'command',
...over,
})
/** An approver that records the request and returns a fixed outcome. */
const approver = (outcome: EscalationOutcome, sink?: (req: unknown) => void): EscalationApprover => ({
request: async (request) => { sink?.(request); return outcome },
})
const ingredients = (over: Partial<Parameters<typeof approveEscalation>[1]> = {}) => ({
approver: approver('allowed-once'),
agent: {},
callId: 'call-1',
toolName: 'bash',
...over,
})
it('grants: returns the requested mode, asking through the approver with the audit reason', async () => {
const seen: { reason?: string }[] = []
const granted = await approveEscalation(req(), ingredients({ approver: approver('allowed-once', r => seen.push(r as { reason?: string })) }))
expect(granted).toBe('workspace-write')
expect(seen[0]?.reason).toBe('escalate sandbox to workspace-write: the user asked to write in the workspace')
})
it('a non-widening request fails closed with its own text and never asks', async () => {
const seen: unknown[] = []
const spy = ingredients({ approver: approver('allowed-once', r => seen.push(r)) })
await expect(approveEscalation(req({ requestedMode: 'read-only' }), spy))
.rejects.toThrow(/not strictly wider than this call's current "read-only" mode/)
await expect(approveEscalation(req({ requestedMode: 'workspace-write', effectiveMode: 'danger-full-access' as never }), spy))
.rejects.toThrow(/not strictly wider/)
expect(seen).toEqual([])
})
it('a missing approval service and an agent-less call each fail closed with distinct text', async () => {
await expect(approveEscalation(req(), ingredients({ approver: undefined }))).rejects.toThrow(/no approval service is composed/)
await expect(approveEscalation(req(), ingredients({ agent: undefined }))).rejects.toThrow(/no agent to route it through/)
})
it('maps each non-grant outcome to its distinct verbatim text (subject in the rejection)', async () => {
await expect(approveEscalation(req({ subject: 'operation' }), ingredients({ approver: approver('rejected') })))
.rejects.toThrow('the user rejected escalating this operation to "workspace-write"')
await expect(approveEscalation(req(), ingredients({ approver: approver('cancelled') })))
.rejects.toThrow('approval for escalating to "workspace-write" was cancelled')
await expect(approveEscalation(req(), ingredients({ approver: approver('unavailable') })))
.rejects.toThrow('no approval channel is available')
})
it('an outcome outside the closed union trips the exhaustiveness guard (defensive)', async () => {
await expect(approveEscalation(req(), ingredients({ approver: approver('bogus' as never) }))).rejects.toThrow()
})
})

View File

@@ -0,0 +1,39 @@
/**
* Tests for the writable-root derivation: the mode's meaning as a canonical
* allow-list. Pinned here so the fs fence and the Seatbelt profile — both
* deriving from `writableRoots` — cannot drift.
*/
import { realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
describe('canonicalPath', () => {
it('resolves symlinks (an existing path realpaths)', () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-'))
expect(canonicalPath(dir)).toBe(realpathSync(dir))
})
it('returns the spelling as-is when the path cannot be resolved (conservative — matches nothing until it exists)', () => {
expect(canonicalPath('/does/not/exist/anywhere-xyz')).toBe('/does/not/exist/anywhere-xyz')
})
})
describe('writableRoots', () => {
it('read-only grants nothing', () => {
expect(writableRoots({ mode: 'read-only', workspaceRoot: process.cwd() })).toEqual([])
})
it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => {
const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-'))
const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
expect(roots).toContain(realpathSync(ws))
expect(roots).toContain(canonicalPath('/tmp'))
expect(roots).toContain(realpathSync(tmpdir()))
// Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide).
expect(new Set(roots).size).toBe(roots.length)
})
})