mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(sandbox): resolve workspace roots per session
This commit is contained in:
@@ -16,7 +16,7 @@ Semantics:
|
||||
|
||||
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
|
||||
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
|
||||
- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
|
||||
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
|
||||
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
|
||||
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
|
||||
|
||||
@@ -29,12 +29,12 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: read-only
|
||||
workspaceRoot: !!js process.cwd()
|
||||
workspaceRoot: !!js process.cwd() # fallback for calls without a session cwd
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
```
|
||||
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
|
||||
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent). The agent-spine e2e additionally drives two concurrent sessions in one Cordis context and proves each real bash tool call can write only its own project. See [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
|
||||
* mode, enforcement, and denial facts. Runner failure means the command never
|
||||
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
|
||||
* processes carry `runnerFailed`. The tool owns approval and passes per-call modes.
|
||||
* processes carry `runnerFailed`. The tool owns approval and passes a complete
|
||||
* per-call policy.
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
@@ -18,21 +19,19 @@ import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } f
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
* the default mode and the `workspace-write` boundary root — is NOT here: it
|
||||
* lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
|
||||
* home both enforcing families read, so bash and fs can never confine to
|
||||
* different roots. The runner choice is likewise the `ctx.sandbox` provider's
|
||||
* config, not this executor's.
|
||||
* the default mode and fallback `workspace-write` root — is NOT here: it lives
|
||||
* on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), which resolves
|
||||
* each calling session's mode and cwd for both enforcing families. The runner
|
||||
* choice is likewise the `ctx.sandbox` provider's config, not this executor's.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
/**
|
||||
* Registers as `ctx.bash` in place of the local executor and requires a
|
||||
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
|
||||
* unchanged. The policy default (mode + workspace root) is the fallback,
|
||||
* while a session override or approved one-shot escalation may select each
|
||||
* call's mode. The prompt does not state the standing mode; `result.sandbox`
|
||||
* reports the mode and enforcement actually used.
|
||||
* unchanged. Tool calls pass the calling session's resolved policy; direct
|
||||
* calls fall back to deployment policy. The prompt does not state the standing
|
||||
* mode; `result.sandbox` reports the mode and enforcement actually used.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static inject = ['sandbox', 'sandboxPolicy']
|
||||
@@ -42,7 +41,6 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
private readonly workspaceRoot: string
|
||||
/**
|
||||
* Per-process confinement facts retained until settlement. Providers may
|
||||
* vary enforcement and diagnostic dialect between overlapping calls, so a
|
||||
@@ -58,11 +56,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// The sandbox default (mode + workspaceRoot) is the one shared policy home
|
||||
// both enforcing families read; injecting sandboxPolicy guarantees it is
|
||||
// constructed first. workspaceRoot arrives already resolved absolute.
|
||||
// The default mode is the capability fact used for schema advertisement;
|
||||
// actual tool executions carry their resolved per-call policy.
|
||||
this.mode = ctx.sandboxPolicy.defaultMode
|
||||
this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
@@ -71,24 +67,22 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the effective mode onto the spec — the request's explicit override
|
||||
* (an approved escalation), else this executor's configured default — so
|
||||
* defaulting stays an explicit resolve step and `run()`/`start()` read the
|
||||
* spec, never the config.
|
||||
* Stamp a complete per-call policy onto the spec. Tool calls supply the
|
||||
* calling session's resolved mode and root; lower-level callers fall back to
|
||||
* the deployment policy.
|
||||
*/
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
return { ...super.resolve(request), sandboxMode: request.sandboxMode ?? this.mode }
|
||||
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
// resolve() always stamps the mode; the cast records that invariant
|
||||
// (mirrors the constructor's config casts).
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') {
|
||||
const result = await super.run(spec)
|
||||
return { ...result, sandbox: { mode, denied: false } }
|
||||
}
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const result = await super.run({ ...spec, command: confined.command })
|
||||
// Runner failure outranks denial because the command did not run. Throw the
|
||||
// same fail-closed error as confine-time discovery with the first stderr line.
|
||||
@@ -99,11 +93,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
}
|
||||
|
||||
override start(spec: BashExecSpec): BashProcess {
|
||||
// Same stamped-by-resolve invariant as run().
|
||||
const mode = spec.sandboxMode as SandboxMode
|
||||
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
|
||||
const { mode } = policy
|
||||
if (mode === 'danger-full-access') return super.start(spec)
|
||||
// Install facts synchronously; promise settlement cannot run before start() returns.
|
||||
const confined = this.confine(spec.command, mode)
|
||||
const confined = this.confine(spec.command, { ...policy, mode })
|
||||
const proc = super.start({ ...spec, command: confined.command })
|
||||
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
|
||||
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
|
||||
@@ -138,13 +132,13 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
* `exec`s into the runner, so no extra shell lingers). Provider errors
|
||||
* (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged.
|
||||
*/
|
||||
private confine(command: string, mode: ConfinedSandboxMode): {
|
||||
private confine(command: string, policy: SandboxPolicy): {
|
||||
command: string
|
||||
enforcement: SandboxEnforcement
|
||||
denialSignatures: readonly string[]
|
||||
runnerFailureSignatures: readonly string[]
|
||||
} {
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], { mode, workspaceRoot: this.workspaceRoot })
|
||||
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
|
||||
return {
|
||||
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
|
||||
enforcement: confined.enforcement,
|
||||
|
||||
@@ -89,7 +89,7 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
|
||||
@@ -94,7 +94,7 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
@@ -72,6 +72,10 @@ function runResult(exitCode: number | null, stderr: string): BashRunResult {
|
||||
return { exitCode, signal: null, timedOut: false, aborted: false, timeoutMs: 1000, stdout: output(''), stderr: output(stderr) }
|
||||
}
|
||||
|
||||
function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd())): SandboxExecutionPolicy {
|
||||
return { mode, workspaceRoot }
|
||||
}
|
||||
|
||||
describe('the provider hand-off', () => {
|
||||
it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
@@ -147,30 +151,31 @@ describe('danger-full-access', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
describe('per-call sandbox policy (the session and escalation carrier)', () => {
|
||||
it('exposes the configured default as the capability fact, and resolve() stamps it', async () => {
|
||||
const { bash } = await setup()
|
||||
expect(bash.sandboxMode).toBe('read-only')
|
||||
expect(bash.resolve({ command: 'true' }).sandboxMode).toBe('read-only')
|
||||
expect(bash.resolve({ command: 'true' }).sandboxPolicy).toEqual(executionPolicy('read-only'))
|
||||
})
|
||||
|
||||
it('an explicit override outranks the default at resolve(), and the wrap policy follows it', async () => {
|
||||
it('an explicit policy outranks the default at resolve(), and the wrap follows its mode and root', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
expect(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }).sandboxMode).toBe('workspace-write')
|
||||
await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
|
||||
const explicit = executionPolicy('workspace-write', '/session/project')
|
||||
expect(bash.resolve({ command: 'true', sandboxPolicy: explicit }).sandboxPolicy).toEqual(explicit)
|
||||
await bash.run(bash.resolve({ command: 'true', sandboxPolicy: explicit }))
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls.map(call => call.policy.mode)).toEqual(['workspace-write', 'read-only'])
|
||||
expect(calls.map(call => call.policy)).toEqual([explicit, executionPolicy('read-only')])
|
||||
})
|
||||
|
||||
it('an escalated run reports the mode it ACTUALLY ran under', async () => {
|
||||
const { bash } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'true', sandboxMode: 'workspace-write' }))
|
||||
const result = await bash.run(bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxMode: 'danger-full-access' }))
|
||||
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') }))
|
||||
expect(result.stdout.text).toBe('free\n')
|
||||
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
|
||||
expect(calls).toHaveLength(0)
|
||||
@@ -181,7 +186,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
// once — anything keyed off the configured default would misreport the
|
||||
// escalated one at its settle stamp.
|
||||
const { bash } = await setup()
|
||||
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxMode: 'workspace-write' }))
|
||||
const escalated = bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') }))
|
||||
const plain = bash.start(bash.resolve({ command: 'true' }))
|
||||
await plain.done
|
||||
await escalated.done
|
||||
@@ -191,7 +196,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => {
|
||||
|
||||
it('an escalated danger-full-access background task carries no facts (nothing confined it)', async () => {
|
||||
const { bash, calls } = await setup()
|
||||
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
|
||||
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') }))
|
||||
await task.done
|
||||
expect(task.sandbox).toBeUndefined()
|
||||
expect(task.readOutput().delta).toContain('bg-free')
|
||||
|
||||
@@ -91,7 +91,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
|
||||
expect(strict.exitCode).not.toBe(0)
|
||||
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxMode: 'workspace-write' }))
|
||||
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
|
||||
expect(retried.exitCode).toBe(0)
|
||||
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
|
||||
|
||||
Reference in New Issue
Block a user