diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5d8ba2d088..3ba3523be9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -181,7 +181,7 @@ export interface Config extends LocalConfig { Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/bash/bash-sandbox/src/index.ts:26`](../packages/bash/bash-sandbox/src/index.ts) +Source: [`packages/bash/bash-sandbox/src/index.ts:27`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -574,7 +574,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:20`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:19`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index bc083ae138..347aefb679 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -2,6 +2,8 @@ Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields. +The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal. + Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only. | Mode | File effects | diff --git a/packages/bash/bash-sandbox/src/helpers.ts b/packages/bash/bash-sandbox/src/helpers.ts new file mode 100644 index 0000000000..a98f47216e --- /dev/null +++ b/packages/bash/bash-sandbox/src/helpers.ts @@ -0,0 +1,49 @@ +/** + * Internal shell-quoting and sandbox-result classification helpers. + * + * @module @deepseek-ai/dsh-bash-sandbox/helpers + */ + +import type { BashRunResult } from '@deepseek-ai/dsh-bash' + +/** + * Quote one string as a single-quoted POSIX shell word. + * @param text - raw argv element to preserve through the outer shell parse. + * @returns the quoted shell word. + */ +export function shellQuote(text: string): string { + return `'${text.replaceAll("'", String.raw`'\''`)}'` +} + +/** + * Classify a failed run against the selected backend's denial dialect. + * @param result - settled foreground run. + * @param signatures - case-insensitive denial substrings from the active wrap. + * @returns whether the failed run matches that denial dialect. + */ +export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean { + return matchesSignature(result.exitCode, result.stderr.text, signatures) +} + +/** + * Classify a failed run against the selected backend's runner-failure dialect. + * @param result - settled foreground run. + * @param signatures - case-insensitive runner-failure substrings from the active wrap. + * @returns whether the failed run matches that runner-failure dialect. + */ +export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean { + return matchesSignature(result.exitCode, result.stderr.text, signatures) +} + +/** + * Match a non-zero exit against case-insensitive stderr signatures. + * @param exitCode - process exit code; null means signal termination. + * @param stderr - collected stderr text. + * @param signatures - substrings identifying the selected backend's dialect. + * @returns whether this is a non-zero exit whose stderr matches a signature. + */ +export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean { + if (exitCode === null || exitCode === 0) return false + const lowered = stderr.toLowerCase() + return signatures.some(signature => lowered.includes(signature.toLowerCase())) +} diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 704cd4604b..124f6b6a9f 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -15,6 +15,7 @@ import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local' +import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts' /** * Plugin config: the local executor's knobs plus the sandbox policy. All @@ -33,52 +34,6 @@ export interface Config extends LocalConfig { workspaceRoot?: string } -/** - * Quote one string as a single-quoted POSIX shell word (embedded single - * quotes become `'\''`), so a wrapped argv element survives the outer - * `bash -c` re-parse byte-for-byte. - * @param text - the raw argv element to quote. - * @returns the single-quoted shell word. - */ -export function shellQuote(text: string): string { - return `'${text.replaceAll("'", String.raw`'\''`)}'` -} - -/** - * Conservatively classify a nonzero, non-signal run using only the selected - * backend's denial signatures. Text inference may miss a denial or match - * unrelated stderr in that dialect; it never uses another backend's terms. - * @param result - the settled foreground run to classify. - * @param signatures - the active wrap's denial dialect, case-insensitive stderr substrings. - * @returns whether the run's failure reads as a sandbox denial. - */ -export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean { - return matchesSignature(result.exitCode, result.stderr.text, signatures) -} - -/** - * Classify a nonzero run using the selected backend's runner-failure - * signatures. Callers check this before denial because runner diagnostics may - * contain denial words; the command did not run. - * @param result - the settled foreground run to classify. - * @param signatures - the active wrap's runner-failure signatures, - * case-insensitive stderr substrings. - * @returns whether the run's failure reads as the runner itself failing. - */ -export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean { - return matchesSignature(result.exitCode, result.stderr.text, signatures) -} - -/** - * Shared classifier for failed runs. Signatures are case-insensitive and may - * include runtime values such as an executable path. - */ -function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean { - if (exitCode === null || exitCode === 0) return false - const lowered = stderr.toLowerCase() - return signatures.some(signature => lowered.includes(signature.toLowerCase())) -} - /** * Registers as `ctx.bash` in place of the local executor and requires a * `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 9a5da8fdb4..237619ae06 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -5,7 +5,8 @@ import { homedir } from 'node:os' 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 { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' /** diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index c8ecc73886..619bc05151 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -13,7 +13,8 @@ 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 { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox' +import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-')) diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 7cc58202fc..cc3703d742 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -5,7 +5,8 @@ import { homedir } from 'node:os' 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 { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' /** diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index fa0ff7257f..cbd765bbf9 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -2,6 +2,8 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly. +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. 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. diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index cc464eb5c4..98dc86d23e 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -7,14 +7,13 @@ */ import { spawnSync } from 'node:child_process' -import { realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { grantArgs as landlockGrantArgs, LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run' +import { LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run' import { Context } from 'cordis' import z from 'schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { @@ -38,77 +37,6 @@ export interface Config { probeTimeoutMs?: number } -/** - * Build a bwrap profile: the host is read-only with fresh `/dev` and `/proc`; - * workspace-write overlays writable temp and workspace mounts. PID and network - * isolation are intentionally outside the file-effect policy. - * - * @param policy - the file-effect policy to express as bwrap arguments. - * @returns the bwrap profile arguments (before the trailing `--` + argv). - */ -export function bwrapProfileArgs(policy: SandboxPolicy): string[] { - const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent'] - if (policy.mode === 'workspace-write') { - args.push('--tmpfs', '/tmp') - args.push('--bind', policy.workspaceRoot, policy.workspaceRoot) - } - return args -} - -/** - * Build Landlock grants for the same file policy without synthetic mounts. - * Read-only grants only `/dev/null` for writes; workspace-write also grants the - * host temp root and workspace. - * - * @param policy - the file-effect policy to express as launcher grants. - * @returns the launcher grant arguments (before `--` + argv). - */ -export function landlockProfileArgs(policy: SandboxPolicy): string[] { - const readWrite = ['/dev/null'] - if (policy.mode === 'workspace-write') { - readWrite.push('/tmp', policy.workspaceRoot) - } - return landlockGrantArgs({ readOnly: ['/'], readWrite }) -} - -/** - * Resolve a granted root to the path the kernel actually sees. Seatbelt path - * filters match the CANONICAL path (symlinks resolved), and the roots this - * profile grants are symlinked on every macOS: `/tmp` is `/private/tmp` and - * the user temp dir lives under `/var` → `/private/var` — an as-spelled - * grant would match nothing. - */ -function canonicalPath(path: string): string { - try { - return realpathSync(path) - } catch { - // An unresolved grant matches nothing until the named path exists; keep its spelling. - return path - } -} - -/** Quote one path as an SBPL string literal (backslashes and double quotes escaped). */ -function sbplString(path: string): string { - return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"` -} - -/** - * Build a Seatbelt profile that denies file writes then allows `/dev/null` and, - * for workspace-write, the canonical workspace, host temp, and per-user macOS - * temp roots. Network and process visibility remain unrestricted. - * - * @param policy - the file-effect policy to express as an SBPL profile. - * @returns the `sandbox-exec` arguments (`-p` + profile, before `--` + 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))] - forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`) - } - return ['-p', forms.join(' ')] -} - /** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */ function defaultProbeBwrap(timeoutMs: number): boolean { const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], { diff --git a/packages/sandbox/sandbox-local/src/profiles.ts b/packages/sandbox/sandbox-local/src/profiles.ts new file mode 100644 index 0000000000..9303583cbb --- /dev/null +++ b/packages/sandbox/sandbox-local/src/profiles.ts @@ -0,0 +1,67 @@ +/** + * Internal platform-profile builders for the local sandbox provider. + * + * @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 type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' + +/** + * Build the bwrap profile arguments for one file-effect policy. + * @param policy - file-effect policy to express as bwrap mounts. + * @returns profile arguments before the trailing separator and command argv. + */ +export function bwrapProfileArgs(policy: SandboxPolicy): string[] { + const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent'] + if (policy.mode === 'workspace-write') { + args.push('--tmpfs', '/tmp') + args.push('--bind', policy.workspaceRoot, policy.workspaceRoot) + } + return args +} + +/** + * Build the Landlock launcher grants for one file-effect policy. + * @param policy - file-effect policy to express as Landlock allow-list grants. + * @returns launcher grant arguments before the trailing separator and command argv. + */ +export function landlockProfileArgs(policy: SandboxPolicy): string[] { + const readWrite = ['/dev/null'] + if (policy.mode === 'workspace-write') { + readWrite.push('/tmp', policy.workspaceRoot) + } + 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. + * @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))] + forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`) + } + return ['-p', forms.join(' ')] +} diff --git a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts index 1e818e1bdf..9a093e4632 100644 --- a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts @@ -6,7 +6,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { bwrapProfileArgs } from '../src/profiles.ts' /** * Keyless backend integration through `confine()` and a real bwrap process. With no rung forced, diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index c221544861..f9efbf992c 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -15,12 +15,10 @@ import { Context } from 'cordis' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { - bwrapProfileArgs, - landlockProfileArgs, LocalSandboxProvider, - seatbeltProfileArgs, } from '@deepseek-ai/dsh-sandbox-local' import type { Config } from '@deepseek-ai/dsh-sandbox-local' +import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../src/profiles.ts' const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index 2c5a026ddb..6d645b1a3b 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -6,7 +6,8 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' -import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' +import { seatbeltProfileArgs } from '../src/profiles.ts' /** * Keyless backend integration through `confine()` and a real macOS Seatbelt process, with Linux