Merge remote-tracking branch 'origin/master' into cross-family-fs-sandbox

# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/cordis.yml
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/permission-switching/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	packages/bash/bash-sandbox/src/index.ts
#	packages/bash/bash/src/types.ts
#	packages/bash/tool-bash/src/index.ts
#	packages/fs/fs/src/index.ts
#	packages/sandbox/sandbox-policy/src/session-mode.ts
#	packages/ui/permission/src/index.ts
#	packages/ui/permission/tests/permission.spec.ts
#	scripts/doc-budgets.manifest.json
This commit is contained in:
kingwl
2026-07-14 21:25:36 +08:00
608 changed files with 7195 additions and 12508 deletions

View File

@@ -1,14 +1,16 @@
# @deepseek-ai/dsh-sandbox-local
Local implementation of the [`@deepseek-ai/dsh-sandbox`](../sandbox/) seam: wraps a caller's argv in a platform confinement runner. Selection is BY PLATFORM, resolved once and cached: each platform names its runner chain, a chain of one is selected directly (probing arbitrates between candidates — a sole candidate leaves nothing to arbitrate), and a chain of several is probed functionally in preference order. Linux: [`bwrap`](https://github.com/containers/bubblewrap) when its probe passes, else the [`landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) Landlock launcher (kernel confinement that needs no userns/mount privileges — see the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the prebuilt-binary decision and profile-parity notes); darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed. A platform with no chain means `confine()` FAILS CLOSED with the seam's structured `SANDBOX_UNAVAILABLE` error (win32 today: a reserved, deliberately empty chain awaiting an AppContainer-family runner); an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap's `runnerFailureSignatures` let the consumer classify that as a sandbox failure rather than a task failure. Never a silent unconfined passthrough on any path.
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.
Policy is per call (`SandboxPolicy`: mode + workspace root); the provider holds only the mechanism and the cached ladder verdict. Every wrap reports the selected runner's `enforcement` (`full`, or `partial` on an older Landlock ABI that governs only a subset of accesses — read from the launcher's `--probe` report line) and its `denialSignatures` — the stderr dialect that rung's kernel speaks on a denied file effect (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt), which stderr-inferring consumers match instead of a cross-runner union. A non-empty `runnerCommand` config is the operator's assertion of a runner that fully enforces the bwrap-shaped profile: the ladder and probes are skipped (the wrap carries both Linux denial dialects, the mechanism being unknown) — also the deterministic fake-runner seam for keyless test tiers. Its runner-failure dialect is the OUTER shell's argv0-scoped failure shapes (`exec: <argv0>: not found`, `<argv0>: No such file or directory`, `<argv0>: Permission denied`) — the consumer re-joins the wrap through `bash -c 'exec …'`, so a missing or unexecutable configured runner classifies as a sandbox failure (fail closed at execution), never as a failing command or a denial. `probeTimeoutMs` (default 5000) bounds each functional probe, the escape hatch for hosts slow enough that a timed-out probe would otherwise misread as `SANDBOX_UNAVAILABLE`.
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.
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.
The Landlock launcher comes from the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) — an entry package (this package's one runtime dependency) plus per-platform binary packages selected by npm's `os`/`cpu` fields, built and released from [its own repository](https://github.com/deepseek-harness/node-addon-landlock-run). The entry package owns the launcher's CLI contract: `launcherPath()` resolution (a host with no platform package yields a never-existing path whose probe fails exactly like an unenforcing kernel), the functional `probe()`, and `grantArgs()` flag spelling — versioned together with the binary, so probe-report parsing can never drift against it. This provider keeps only the policy side: the mode → grants mapping (`landlockProfileArgs`) and the ladder. The consumer path is rehearsed by `tests/packed-install.e2e.ts`: pack THIS package's closure, install into a throwaway consumer with the launcher family coming from the registry, assert the installed binary executable (a stripped mode bit must not masquerade as a non-enforcing kernel), and confine through it under plain `node`.
[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift.
Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, `tests/seatbelt.e2e.ts`), each self-skipping where its runner is absent; CI's `sandbox-e2e` matrix runs all of them against real kernels (bwrap plus one Landlock leg per architecture on Linux, Seatbelt on macOS) and fails on a silent all-skip.
Each rung has a self-skipping keyless world-effect test; CI runs platform legs against real kernels and rejects a silent all-skip. The packed-install test exercises the registry launcher and executable mode through a plain-Node consumer.
```yaml
- id: sandbox
@@ -16,3 +18,15 @@ Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2
```
Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the acp-agent example](../../../examples/acp-agent/) for the runnable default composition.
## Model Experience
Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), which render this provider's enforcement and denial facts while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection and profiles stay outside context.
## Known Limitations and Deferred Work
- **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred.
- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it.
- **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes.
- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly.

View File

@@ -1,24 +1,8 @@
/**
* `LocalSandboxProvider`: the local implementation of the
* `@deepseek-ai/dsh-sandbox` seam. Wraps a caller's argv in a platform
* confinement runner selected BY PLATFORM: each platform names its runner
* chain ({@link PLATFORM_CHAINS}), a chain of one is selected directly (no
* probe — there is nothing to arbitrate), and a chain of several is probed
* FUNCTIONALLY in preference order (build and enforce a real profile once,
* not `--version`), the verdict cached for the provider's lifetime. Linux:
* `bwrap`, else the `landlock-run` Landlock launcher (kernel confinement
* that needs no userns/mount privileges; distributed as the npm package
* family `node-addon-landlock-run` — the decision recorded in
* docs/rfc/implemented/feature/2026-07-06-sandbox.md); darwin: macOS
* `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed.
* When the platform has no chain or no candidate passes,
* {@link LocalSandboxProvider.confine} FAILS CLOSED with the seam's
* structured `SANDBOX_UNAVAILABLE` error instead of passing the argv
* through unconfined; an unusable runner selected WITHOUT a probe fails
* closed at execution time instead (it refuses to run the command), which
* the wrap's `runnerFailureSignatures` let consumers classify as a sandbox
* failure rather than a task failure.
*
* Local sandbox backend. It selects the platform runner chain (Linux bwrap then
* Landlock; macOS Seatbelt), functionally probes competing candidates once, and
* reports each wrap's enforcement and stderr dialects. Missing or unusable
* confinement fails closed rather than returning the original argv.
* @module @deepseek-ai/dsh-sandbox-local
*/
@@ -35,20 +19,10 @@ import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPoli
/** Plugin config. All optional — `static Config` supplies the defaults. */
export interface Config {
/**
* Override the sandbox runner argv (the bwrap-shaped profile arguments are
* appended). A NON-EMPTY argv is the operator's assertion that this runner
* exists and FULLY enforces the profile (confinement reports
* `enforcement: 'full'`, and — the runner's kernel mechanism being unknown
* — carries both Linux file-denial dialects as its denial signatures) —
* the runner chain and its probes are skipped,
* and a broken runner fails loudly at execution time. The operator also
* supplies {@link runnerFailureSignatures}, which distinguish the runner
* refusing its profile from the wrapped command failing normally.
* Absent (or empty — the schema normalizes an omitted array to `[]`): the
* built-in platform chains — Linux `bwrap` then the Landlock launcher
* (probed in that order), darwin `sandbox-exec` (the sole candidate,
* selected without a probe). Used for custom/alternative runners and
* for deterministic fake runners in keyless test tiers.
* Override the runner argv; bwrap-shaped profile arguments are appended. A
* non-empty override asserts full enforcement and skips built-in selection and
* probing; a broken runner then fails at execution and must be identifiable by
* {@link runnerFailureSignatures}.
*/
runnerCommand?: string[]
/**
@@ -60,28 +34,15 @@ export interface Config {
* own failure dialect.
*/
runnerFailureSignatures?: string[]
/**
* Per-probe timeout in milliseconds for the chain's functional probes
* (default: 5000; must be a positive finite number — Node treats a 0
* `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A
* probe that exceeds it reads as an unusable rung, so a
* host slow enough to trip the default — cold NFS mounts, heavily loaded
* CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no
* config escape. Bounds ONE probe, and the chain walk runs each at most once
* per provider lifetime.
*/
/** Positive timeout for each functional probe; zero would mean unbounded to Node. */
probeTimeoutMs?: number
}
/**
* The `bwrap` profile arguments for one policy. The whole host tree is bound
* read-only; a fresh `/dev` keeps `>/dev/null` redirects working and a fresh
* `/proc` keeps process-inspecting tools working. `workspace-write`
* additionally mounts an ephemeral writable `/tmp` and rebinds the workspace
* root read-write (bind order matters: later binds overlay earlier ones).
* Deliberately NO `--unshare-pid` (it would break the process-group kill
* semantics shell consumers rely on) and NO network unsharing (the seam's
* mode vocabulary promises file effects only).
* 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).
*/
@@ -95,19 +56,10 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
}
/**
* The `landlock-run` grant arguments for one policy — the bwrap
* profile's file-effect semantics expressed as a Landlock allow-list
* (Landlock cannot mount, so there are no fresh/ephemeral filesystems). The
* whole tree is readable and executable; of `/dev`, ONLY `/dev/null` is
* writable — a whole-`/dev` grant would expose real host paths beneath it
* (`/dev/shm`, a shared tmpfs) to persistent writes, which `read-only`
* promises never happen. bwrap can hand out a fresh ephemeral `/dev`; on the
* host's own `/dev` the write grant must be node-by-node, and `>/dev/null`
* is the one redirects need. `workspace-write` adds the HOST `/tmp` (shared
* and persistent, where bwrap's is ephemeral — the honest difference,
* recorded in the sandbox RFC's runner notes) plus the workspace
* root read-write. The flag spelling belongs to `node-addon-landlock-run`'s
* `grantArgs`; this function owns only the policy → grants mapping.
* 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).
*/
@@ -130,10 +82,7 @@ function canonicalPath(path: string): string {
try {
return realpathSync(path)
} catch {
// realpathSync failed: the path (or a prefix) is missing or unreadable.
// Grant the spelling as-is — an unresolvable root matches nothing until
// it exists, which is the conservative outcome, and inventing a fallback
// resolution here would grant a path the caller never named.
// An unresolved grant matches nothing until the named path exists; keep its spelling.
return path
}
}
@@ -144,20 +93,10 @@ function sbplString(path: string): string {
}
/**
* The `sandbox-exec` arguments for one policy: `-p` plus a Seatbelt (SBPL)
* profile with the same file-effect semantics as the other dialects, built
* as allow-default → `(deny file-write*)` → write allow-list (later rules
* win), so exactly the mode's promised file effects are governed — network
* and process visibility stay unrestricted, which is all the seam's mode
* vocabulary claims. Of `/dev`, ONLY the `/dev/null` literal is writable
* (the same node-not-directory reasoning as the Landlock grant).
* `workspace-write` adds the workspace root, the host `/tmp`, and the
* per-user darwin temp dir (`os.tmpdir()`, launchd's `TMPDIR`, inherited by
* the confined child) — on darwin that directory IS the platform's `/tmp`
* for every mkstemp-family tool, so omitting it would deny the mode's
* promised temp area. All granted roots are canonicalized because Seatbelt
* matches resolved paths ({@link canonicalPath}); duplicates after
* resolution collapse.
* 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).
*/
@@ -170,17 +109,7 @@ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
return ['-p', forms.join(' ')]
}
/**
* Functional `bwrap` probe: can it actually build the read-only profile on
* this host? (`--version` alone would miss a disabled unprivileged user
* namespace.) Synchronous by design — it runs once, lazily, before the first
* confined wrap, and the chain's verdict is cached for the provider's
* lifetime. `timeoutMs` bounds the probe (the `probeTimeoutMs` config).
* The Landlock rung needs no such helper: resolution (`launcherPath`) and
* the functional probe (`probe`) come from `node-addon-landlock-run`, the
* package family that ships the launcher binary itself, so the probe-report
* parsing can never drift against the binary.
*/
/** 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'], {
timeout: timeoutMs,
@@ -239,13 +168,10 @@ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement:
const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
linux: ['bwrap', 'landlock'],
darwin: ['seatbelt'],
// Reserved slot, deliberately empty: Windows support fills it with a
// confinement runner (AppContainer / restricted-token family, shipped from
// its own repository on the landlock-run template) plus a
// SelectedRunner['runner'] union member — the switches' assertNever guards
// then walk the implementer to every site. An empty chain fails closed at
// confine(), identical to an unlisted platform: reserving the slot never
// weakens the fail-closed end.
// Reserved slot, deliberately empty: Windows support fills it with a confinement runner
// (AppContainer / restricted-token family, shipped from its own repository on the
// landlock-run template) plus a SelectedRunner['runner'] union member — the switches'
// assertNever guards then walk the implementer to every site.
win32: [],
}
@@ -276,16 +202,9 @@ function assertPositiveFinite(name: string, value: number): void {
}
/**
* The denial dialect each runner's kernel speaks — the case-insensitive
* stderr substrings a denied file effect produces under it, carried on every
* wrap (the seam's `ConfinedArgv.denialSignatures`). Kernel facts, not
* tunables: bwrap denies through its read-only bind mounts (EROFS), Landlock
* refuses with EACCES, Seatbelt with EPERM — whose text is also what
* non-file EPERM boundaries print, the residual imprecision the consumer's
* conservative classifier documents. An operator-configured `runnerCommand`
* has an unknown kernel mechanism, so its wraps carry both Linux file-denial
* dialects; bare EPERM stays excluded there (it names non-file boundaries
* the mode vocabulary does not govern).
* The denial dialect each runner's kernel speaks — the case-insensitive stderr substrings a
* denied file effect produces under it, carried on every wrap (the seam's
* `ConfinedArgv.denialSignatures`).
*/
const DENIAL_SIGNATURES = {
bwrap: ['read-only file system'],
@@ -295,15 +214,9 @@ const DENIAL_SIGNATURES = {
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
/**
* How each runner's OWN failure identifies itself on stderr (the seam's
* `ConfinedArgv.runnerFailureSignatures`): every runner prefixes its error
* lines with its program name, and the shell's runner-not-found message
* carries the same `name: ` shape (`bash: bwrap: command not found`,
* `bash: …/bin/landlock-run: No such file or directory`) — so one substring
* per runner covers both "runner broke" and "runner missing". Consumers
* match these BEFORE the denial dialect: a runner's error text can contain
* denial words (an unopenable grant root reports `Permission denied`), and
* a runner failure means the command never ran at all.
* Runner-owned stderr prefixes cover both internal refusal and shell-level
* not-found errors. Consumers match these before denial text because the
* command never ran on this path.
*/
const RUNNER_FAILURE_SIGNATURES = {
bwrap: ['bwrap: '],
@@ -356,17 +269,15 @@ export class LocalSandboxProvider extends SandboxProvider {
}
/**
* 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. Every wrap carries the runner's enforcement completeness, its
* denial dialect, and its runner-failure signatures.
* 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 runner-failure signatures;
* throws the fail-closed `SANDBOX_UNAVAILABLE` error when the platform
* has no usable runner.
* @returns the wrapped argv plus the selected backend's enforcement completeness, denial
* signatures, and runner-failure signatures; 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) {
@@ -375,14 +286,9 @@ export class LocalSandboxProvider extends SandboxProvider {
argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
enforcement: 'full',
denialSignatures: DENIAL_SIGNATURES.runnerCommand,
// The operator names the configured runner's OWN pre-exec refusal
// dialect; the consumer additionally re-joins the wrap through an
// outer `bash -c 'exec …'`, so we can add the missing/unexecutable
// outer-shell shapes ourselves. Scoping every automatic shape to
// argv0 keeps in-command errors out (a bare `exec:`/`Permission
// denied` prefix would claim tool output; `exec: <argv0>: not found`
// cannot). The residual text-collision trade is documented by the
// seam's conservative classifier contract.
// The operator names the configured runner's own pre-exec refusal dialect; the consumer
// additionally re-joins the wrap through an outer `bash -c 'exec …'`, so we can add the
// missing/unexecutable outer-shell shapes ourselves.
runnerFailureSignatures: [
...this.configuredRunnerFailureSignatures,
`exec: ${argv0}: not found`,
@@ -428,11 +334,7 @@ export class LocalSandboxProvider extends SandboxProvider {
const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? []
const [first, ...rest] = chain
if (first === undefined) return 'unavailable'
// One candidate = nothing to arbitrate: select it without probing. Its
// runner fails closed at EXECUTION time if unusable (refuses to run the
// command), and the wrap's runnerFailureSignatures let the consumer
// classify that as a sandbox failure — never a silent unconfined run,
// never a plain task failure.
// 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)

View File

@@ -9,22 +9,11 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
/**
* KEYLESS bwrap integration proof for the BACKEND: the REAL `bwrap` confining
* REAL processes through `confine()` + a direct spawn of the returned argv.
* Nothing is forced off: bwrap is the ladder's FIRST rung, so a passing probe
* selects it naturally — the wrap shape assertion pins that. Verifies the
* WORLD (files exist or don't) and that the kernel's denial text matches the
* dialect the wrap advertises; the through-`ctx.bash` consumer proof lives
* with `@deepseek-ai/dsh-bash-sandbox`.
*
* Self-skips wherever the functional probe fails — no `bwrap` on PATH, or a
* host that denies unprivileged user namespaces (the probe is the same
* profile the provider enforces, so skip conditions match runtime exactly).
*
* Workspaces for the workspace-write tests live under the HOME directory on
* purpose: bwrap's `/tmp` is an EPHEMERAL mount (the documented
* bwrap-profile difference — pinned by its own test below), so only a
* workspace OUTSIDE `/tmp` proves the workspace-root rebind itself.
* Keyless backend integration through `confine()` and a real bwrap process. With no rung forced,
* a passing probe must select the first rung. Tests assert world effects, wrap shape, and that the
* kernel denial matches the advertised dialect; consumer coverage lives in dsh-bash-sandbox.
* Skips when bwrap or user namespaces are unavailable. HOME-based workspaces avoid bwrap's
* ephemeral `/tmp`, so workspace-write actually proves the workspace-root rebind.
*/
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })

View File

@@ -10,22 +10,10 @@ import { launcherPath } from 'node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
/**
* KEYLESS Landlock integration proof for the BACKEND: the REAL npm-distributed
* `landlock-run` launcher (`node-addon-landlock-run`) confining REAL processes through `confine()` + a direct
* spawn of the returned argv, with the bwrap rung forced off so the ladder
* lands on the launcher. Verifies the WORLD (files exist or don't), not the
* wrapper argv alone; the through-`ctx.bash` consumer proof lives with
* `@deepseek-ai/dsh-bash-sandbox`.
*
* Self-skips when the running kernel does not enforce Landlock (or this
* platform has no launcher package — the probe cannot pass then). The
* binary itself arrives with `pnpm install`, so absence is not a checkout
* state.
*
* Workspaces live under the HOME directory on purpose: `workspace-write`
* grants the host `/tmp` wholesale (the documented Landlock-profile
* difference), so only a workspace OUTSIDE `/tmp` proves the workspace-root
* grant itself.
* Keyless backend integration through `confine()` and the registry `landlock-run` launcher, with
* bwrap forced off. Tests assert real world effects; consumer coverage lives in dsh-bash-sandbox.
* Skips when the platform package or enforcing kernel is unavailable. HOME-based workspaces avoid
* Landlock's wholesale `/tmp` grant, so workspace-write proves the workspace-root grant itself.
*/
const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' })

View File

@@ -252,19 +252,15 @@ describe('the platform chains', () => {
})
it('a rogue chain entry throws via the probe walk\'s exhaustiveness guard (closed union)', async () => {
// Same convention as the wrap switch below: the union is closed, so a
// runner added later fails to compile at the probe switch instead of
// silently selecting without a probe. Only a cast can reach the guard.
// Same convention as the wrap switch below: the union is closed, so a runner added later
// fails to compile at the probe switch instead of silently selecting without a probe.
const { sandbox } = await setup({}, { chain: ['chroot', 'bwrap'] as unknown as readonly ['bwrap'] })
expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant')
})
it('a rogue cached runner tag throws via the exhaustiveness guard (closed union)', async () => {
// The wrap switches on the chain verdict's runner tag and ends with
// assertNever: a rogue tag (only reachable by a cast — the union is
// closed and chainVerdict writes only its own literals) must throw, so a
// runner added later fails to compile at the switch instead of silently
// wrapping with another runner's dialect.
// Only a cast can create this rogue closed-union tag. It must hit `assertNever`, ensuring a new
// runner cannot silently use another runner's wrap or denial dialect.
const { sandbox } = await setup()
;(sandbox as unknown as { selectedRunner: unknown }).selectedRunner = { runner: 'chroot', enforcement: 'full' }
expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant')

View File

@@ -7,30 +7,14 @@ import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
/**
* KEYLESS publish-path rehearsal for this package's own distribution: the
* provider must work from its PACKED tarball plus its REGISTRY launcher
* dependency, not the git checkout. `pnpm pack` produces the EXACT bytes
* `pnpm publish` would upload; this suite packs the workspace closure
* (`dsh-sandbox-local` + its `@deepseek-ai` peers), installs the tarballs
* into a throwaway consumer OUTSIDE the repo — npm resolving the
* `node-addon-landlock-run` dependency (and its os/cpu-selected platform
* package) from the public registry, the real consumer path — and drives
* the INSTALLED packages under plain `node`: no tsx, no tsconfig paths, no
* workspace resolution, so a `files`-list omission, a broken launcher
* dependency, or a mode-stripped binary fails here instead of at the first
* real install.
* Keyless publish-path rehearsal. It packs the package and workspace peers, installs those exact
* tarballs in an external plain-Node consumer, and lets npm resolve the registry Landlock launcher
* plus its platform package. No tsx, path mapping, or workspace resolution can hide missing files,
* dependency errors, or lost executable modes.
*
* World-proofs: the registry-installed launcher carries this host's ELF
* architecture and IS executable (a tarball that loses the mode bit would
* otherwise masquerade as a non-enforcing kernel — the fail-closed branch
* below must never absorb that), and the installed provider confines a real
* process THROUGH it (bwrap forced off) — or fails closed when the running
* kernel does not enforce Landlock, which is itself the installed
* fail-closed contract. Byte provenance of the launcher is the
* `node-addon-landlock-run` repository's own release-pipeline concern.
*
* Self-skips off Linux or when the built `lib/` is absent (run
* `pnpm run build` first — CI's landlock legs do).
* The installed launcher must match the host architecture, remain executable, and either confine a
* real process with bwrap disabled or fail closed on a non-enforcing kernel. Skips off Linux or
* before `pnpm run build`; launcher byte provenance belongs to its upstream release pipeline.
*/
const packageDir = fileURLToPath(new URL('..', import.meta.url))
@@ -84,11 +68,8 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-
tarballs.push(lines[lines.length - 1] as string)
}
// A real consumer: plain ESM project, tarballs installed by npm — the
// peer ranges (^0.0.1) resolve to the tarball versions, cordis pins to
// the peer range's rc, and `node-addon-landlock-run` (with its
// os/cpu-selected platform package, an OPTIONAL dependency of the entry
// — so no `--omit=optional` here) comes from the public registry.
// Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional
// dependencies because the launcher selects its OS/CPU package through one.
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.6'], {
cwd: consumerDir,

View File

@@ -9,20 +9,11 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
/**
* KEYLESS Seatbelt integration proof for the BACKEND: the REAL macOS
* `sandbox-exec` confining REAL processes through `confine()` + a direct
* spawn of the returned argv, with the Linux rungs forced off so the ladder
* lands on Seatbelt. Verifies the WORLD (files exist or don't) and that the
* kernel's denial text matches the dialect the wrap advertises; the
* through-`ctx.bash` consumer proof lives with `@deepseek-ai/dsh-bash-sandbox`.
*
* Self-skips wherever the functional probe fails — every non-macOS host, or
* a macOS whose `sandbox-exec` refuses the profile.
*
* Workspaces for the workspace-write tests live under the HOME directory on
* purpose: `workspace-write` grants `/tmp` and the per-user temp dir
* wholesale (the documented Seatbelt-profile temp areas), so only a
* workspace OUTSIDE both proves the workspace-root grant itself.
* Keyless backend integration through `confine()` and a real macOS Seatbelt process, with Linux
* rungs forced off. Tests assert world effects and that the kernel denial matches the advertised
* dialect; consumer coverage lives in dsh-bash-sandbox. Skips off macOS or when the profile probe
* fails. HOME-based workspaces avoid Seatbelt's wholesale temp-directory grants, so
* workspace-write proves the workspace-root grant itself.
*/
const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' })

View File

@@ -21,3 +21,12 @@ Two families enforce the same mode vocabulary: the sandboxed bash executor (`@de
## 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.
## 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

@@ -9,3 +9,24 @@ Policy rides the call, not the provider: two consumers may confine under differe
**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).
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]`).
## Model Experience
### Confinement error, indirectly
**What the model sees**: Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), failure to enforce a requested mode produces code `SANDBOX_UNAVAILABLE` and the exact error below. An execution-time runner failure adds ` Runner failure: <detail>`.
**Token effect**: Conditional error text is visible for that call and retained in history until compaction.
#### Exact error
```markdown
sandbox mode "<mode>" 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) — Windows has no confinement backend yet — or switch the consumer to danger-full-access.
```
## Known Limitations and Deferred Work
- **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions.
- **Same-world confinement only** — containers, microVMs, and remote execution require replacing capability implementations rather than adding a provider here.
- **Denial reporting is a stderr dialect** — the seam returns backend signatures instead of a typed runtime denial channel, so consumers that need classification must infer it from the child process's output.
- **One provider per context** — composing different sandbox mechanisms simultaneously requires a provider-level ladder or separate Cordis contexts; callers choose policy per call, not backend identity.

View File

@@ -1,24 +1,7 @@
/**
* The process-sandbox seam (`ctx.sandbox`): an abstract service defining WHAT
* platform confinement does — wrap a subprocess argv so it executes under a
* file-effect policy — without saying HOW. Implementations subclass
* {@link SandboxProvider} and register as the `sandbox` service;
* `@deepseek-ai/dsh-sandbox-local` (per-platform chains: Linux `bwrap` then the
* npm-distributed `landlock-run` launcher, macOS `sandbox-exec`/Seatbelt) is
* the first.
* Consumers hand over the exact argv they are about to spawn
* (`@deepseek-ai/dsh-bash-sandbox` wraps `['bash', '-c', command]`; a
* subagent backend wraps its child-agent argv) and spawn the returned argv
* instead.
*
* The seam confines SAME-WORLD subprocesses only: a backend shares the
* host's filesystem and kernel, and the policy's `workspaceRoot` names a
* real host path. Containers, microVMs, and remote executors are NOT
* backends of this seam — they are sibling implementations of whole
* capability seams (`ctx.bash`, `ctx.fs`), deployed as environment-coherent
* groups; the boundary is recorded in
* docs/rfc/implemented/feature/2026-07-06-sandbox.md.
*
* Same-world process-confinement 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
*/
@@ -37,25 +20,10 @@ export type { EscalationApproval, EscalationApprover, EscalationOutcome, Escalat
export { canonicalPath, writableRoots } from './roots.ts'
/**
* File-effect policy a sandbox backend enforces on confined processes.
*
* - `read-only` — the process cannot write the filesystem anywhere; a
* write-shaped `/dev/null` sink stays available so `>/dev/null` redirects
* keep working (HOW is the backend's choice: bwrap mounts a fresh `/dev`,
* the Landlock launcher and Seatbelt grant the single `/dev/null` node).
* - `workspace-write` — writes are allowed only under the policy's
* workspace root and `/tmp`; everything else stays read-only. Which `/tmp`
* is backend-specific — an ephemeral mount under bwrap, the HOST `/tmp`
* under the Landlock launcher, the host `/private/tmp` plus the per-user
* darwin temp dir under Seatbelt: the seam promises the write boundary,
* not the mount's nature.
* - `danger-full-access` — no confinement; a consumer configured with it
* spawns its argv unwrapped and never calls the provider.
*
* The mode governs FILE effects only: network and process visibility are not
* restricted (a backend that cannot honestly enforce them must not pretend
* to). How completely the file effects themselves are enforced is likewise a
* reported fact, not an assumption — see {@link SandboxEnforcement}.
* 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'
@@ -63,18 +31,9 @@ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
export type ConfinedSandboxMode = Exclude<SandboxMode, 'danger-full-access'>
/**
* How completely the selected backend enforces a confined mode's file
* effects.
*
* - `full` — every file effect the mode promises to block is governed: the
* `bwrap` mount profile, a Landlock kernel enforcing the launcher's whole
* ruleset, or an operator-configured runner (configuring one asserts full
* enforcement along with existence).
* - `partial` — the backend is active but the kernel governs only the subset
* of accesses its ABI knows (an older Landlock ABI: path-based truncate is
* ungoverned before ABI v3), so a file effect the mode promises to block
* may still land. A caller that needs the mode's promise to be absolute
* must treat `partial` as outside that promise.
* 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'
@@ -114,36 +73,25 @@ export interface ConfinedArgv {
*/
denialSignatures: readonly string[]
/**
* How the RUNNER ITSELF failing identifies itself: case-insensitive stderr
* substrings produced when the sandbox binary is missing, refuses its
* profile, or fails closed before exec'ing the command (`bwrap: `,
* `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own
* error prefix and the shell's runner-not-found message). ORTHOGONAL to
* {@link denialSignatures}: a denial is the confined COMMAND being blocked
* (the sandbox working as designed); a runner failure means the command
* NEVER RAN and must surface as a sandbox failure, not a task failure —
* consumers check these signatures FIRST (a runner's own error text may
* contain denial words, e.g. an unopenable grant root reporting
* `Permission denied`).
* Case-insensitive signatures for runner failure before command execution.
* Consumers check these before denial signatures: runner failure means the
* command never ran, while denial means confinement worked and blocked it.
*/
runnerFailureSignatures: readonly string[]
}
/**
* Error `code` carried by the infrastructure error a provider throws when a
* confined policy is requested but no backend is available or usable on this
* host: confinement FAILS CLOSED (refuses to run) rather than silently
* executing unconfined. Thrown as a `HarnessError`, it reaches the model
* through the structured `{ name, code }` error channel on `tool/result`, so
* callers can distinguish "the sandbox is missing" from a failing command.
* Error code for a requested confined mode when no backend is usable. The
* provider fails closed, and `HarnessError` carries the code through
* `tool/result` so callers can distinguish missing confinement from command
* failure.
*/
export const SANDBOX_UNAVAILABLE = 'SANDBOX_UNAVAILABLE'
/**
* Thrown by {@link SandboxProvider.confine} when a confined policy is
* requested but no backend is usable on this host: confinement fails closed.
* Carries the {@link SANDBOX_UNAVAILABLE} code through the structured
* `{ name, code }` error channel.
* Thrown when {@link SandboxProvider.confine} cannot enforce the requested
* mode. Carries {@link SANDBOX_UNAVAILABLE} through the structured error
* channel.
*/
export class SandboxUnavailableError extends HarnessError {
constructor(mode: ConfinedSandboxMode, detail?: string) {
@@ -166,27 +114,10 @@ declare module 'cordis' {
}
/**
* Abstract process-sandbox service. Subclass, implement {@link confine}, and
* load the subclass as a plugin — it registers as `ctx.sandbox` (one
* implementation per context; loading a second throws, cordis' standard
* duplicate-service behavior).
*
* Semantics every implementation must honor:
* - {@link confine} either returns an argv whose runner ENFORCES the policy
* or fails closed — at `confine` time with {@link SandboxUnavailableError}
* (no backend for this host), or at EXECUTION time by the runner itself
* refusing to run the command (exiting without exec'ing it, identified by
* {@link ConfinedArgv.runnerFailureSignatures}). A silent unconfined
* passthrough is never a legal outcome on either path.
* - Probing exists to ARBITRATE between multiple candidate backends and may
* be skipped when a platform has exactly one: the sole candidate is
* selected directly and the runner's exec-time fail-closed refusal carries
* the safety property. When probing does run, it is functional (actually
* enforcing a profile, not a version check), at most once per provider
* lifetime; `confine` itself spawns nothing beyond that one-time probing.
* - The returned {@link ConfinedArgv.enforcement} states the backend's
* actual completeness for THIS host; `partial` is reported, never silently
* upgraded to `full`.
* 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 {
constructor(ctx: Context) {