feat(sandbox): cross-family file sandbox — one policy home, sandboxed fs provider, fs escalation parity
Extend SandboxMode enforcement from bash to the filesystem tools, the sandbox RFC's deferred cross-family phase. - dsh-sandbox-policy (new, ctx.sandboxPolicy): the single home for the deployment default mode + workspaceRoot and the per-session override event, renamed bash/sandbox-mode -> sandbox/mode and moved here with its fold/setter. Decouples the bash seam from dsh-session. - dsh-fs-sandbox (new): SandboxedFileSystem extends LocalFileSystem and fences write/edit by the per-call mode (read-only denies, workspace-write contains to the workspace + temp roots via the shared writableRoots, danger passes through); reads pass through. Structured FS_SANDBOX_DENIED; in-lock parent re-canonicalization. A policy fence in trusted code, not a kernel boundary. - dsh-sandbox: the shared escalation kit (writableRoots, the strictly-wider ladder, denial/hint markers, approveEscalation) both tool families use; approveEscalation takes a structural approver so dsh-sandbox gains no approval/agent dependency, and both tools stay duplication-free. - tool-fs: write/edit advertise sandbox_permissions/justification under a confining ctx.fs, map FS_SANDBOX_DENIED to the shared [sandbox: ...] marker, and resolve the same one-approved-wider retry. - examples/acp-agent: composes sandbox-policy + fs-sandbox, drops the gating that disabled the fs stack under confined modes. RFC docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md; the old sandbox RFC's In-process/deferred/FAQ sections updated to shipped fact.
This commit is contained in:
@@ -25,16 +25,15 @@
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"node-addon-landlock-run": "0.0.0-test.0",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -41,31 +41,23 @@
|
||||
* @module @deepseek-ai/dsh-bash-sandbox
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs plus the sandbox policy. All
|
||||
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
|
||||
* fail-safe default; an example that wants a workspace-writable agent opts in
|
||||
* explicitly). The runner choice is NOT configured here: which platform
|
||||
* backend confines the command is the `ctx.sandbox` provider's config.
|
||||
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
|
||||
* the default mode and the `workspace-write` boundary root — is NOT here: it
|
||||
* lives on `ctx.sandboxPolicy` (`@deepseek-ai/dsh-sandbox-policy`), the one
|
||||
* home both enforcing families read, so bash and fs can never confine to
|
||||
* different roots. The runner choice is likewise the `ctx.sandbox` provider's
|
||||
* config, not this executor's.
|
||||
*/
|
||||
export interface Config extends LocalConfig {
|
||||
/** File-sandbox mode commands run under (default: `read-only`). */
|
||||
mode?: SandboxMode
|
||||
/**
|
||||
* Root directory `workspace-write` mode may write under (default: the
|
||||
* executor's default working directory — `cwd`, else `process.cwd()`).
|
||||
*/
|
||||
workspaceRoot?: string
|
||||
}
|
||||
export type Config = LocalConfig
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word (embedded single
|
||||
@@ -141,24 +133,18 @@ function matchesSignature(exitCode: number | null, stderr: string, signatures: r
|
||||
* INSTEAD OF `dsh-bash-local`, together with a `ctx.sandbox` provider, is
|
||||
* the whole swap — the tool layer is untouched). Its configured mode is the
|
||||
* fallback exposed by {@link sandboxMode}; `dsh-tool-bash` folds a session's
|
||||
* durable `bash/sandbox-mode` override and stamps the effective mode onto each
|
||||
* durable `sandbox/mode` override and stamps the effective mode onto each
|
||||
* request, while an approved escalation may stamp a strictly wider mode for
|
||||
* one call. The tool's per-agent prompt section states that same effective
|
||||
* mode, and each run's `result.sandbox` reports what actually executed plus
|
||||
* enforcement completeness.
|
||||
*/
|
||||
export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
static inject = ['sandbox']
|
||||
static inject = ['sandbox', 'sandboxPolicy']
|
||||
|
||||
// The sandbox-specific fields intersect the local executor's Config as an
|
||||
// inline schema call: the config catalog walks `static Config` statically.
|
||||
static override Config: z<Config> = z.intersect([
|
||||
LocalBashExecutor.Config,
|
||||
z.object({
|
||||
mode: z.union(['read-only', 'workspace-write', 'danger-full-access'] as const).default('read-only'),
|
||||
workspaceRoot: z.string(),
|
||||
}),
|
||||
])
|
||||
// No own Config: the sandbox default (mode + workspaceRoot) moved to
|
||||
// ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config
|
||||
// verbatim (the config catalog walks the inherited static).
|
||||
|
||||
private readonly mode: SandboxMode
|
||||
private readonly workspaceRoot: string
|
||||
@@ -182,12 +168,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
// schemastery (static Config) already filled the defaulted fields — the
|
||||
// cast records that runtime fact (mirrors LocalBashExecutor's config
|
||||
// cast). `workspaceRoot` and `cwd` have NO schema default, so their
|
||||
// fallback chain is real branching.
|
||||
this.mode = config.mode as SandboxMode
|
||||
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
|
||||
// The sandbox default (mode + workspaceRoot) is the one shared policy home
|
||||
// both enforcing families read; injecting sandboxPolicy guarantees it is
|
||||
// constructed first. workspaceRoot arrives already resolved absolute.
|
||||
this.mode = ctx.sandboxPolicy.defaultMode
|
||||
this.workspaceRoot = ctx.sandboxPolicy.workspaceRoot
|
||||
}
|
||||
|
||||
/** The configured default mode — the capability fact the tool layer reads. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
@@ -46,7 +47,8 @@ async function tempDir(base: string): Promise<string> {
|
||||
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise<SandboxBashExecutor> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { launcherPath } from 'node-addon-landlock-run'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
@@ -45,7 +46,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
@@ -39,9 +40,15 @@ const passthrough = (argv: readonly string[]): ConfinedArgv =>
|
||||
|
||||
/**
|
||||
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
|
||||
* per test) and the executor under test on top of it.
|
||||
* per test), the shared `ctx.sandboxPolicy` (mode + workspaceRoot), and the
|
||||
* executor under test on top of them. `mode`/`workspaceRoot` route to the
|
||||
* policy service; the rest (cwd, graceMs, timeoutMs) to the executor.
|
||||
*/
|
||||
async function setup(config: Config = {}, behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough) {
|
||||
async function setup(
|
||||
config: { mode?: SandboxMode; workspaceRoot?: string } & Config = {},
|
||||
behavior: (argv: readonly string[], policy: SandboxPolicy) => ConfinedArgv = passthrough,
|
||||
) {
|
||||
const { mode, workspaceRoot, ...execConfig } = config
|
||||
const calls: ConfineCall[] = []
|
||||
class FakeSandboxProvider extends SandboxProvider {
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
@@ -51,7 +58,11 @@ async function setup(config: Config = {}, behavior: (argv: readonly string[], po
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(FakeSandboxProvider)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...config })
|
||||
await ctx.plugin(SandboxPolicyService, {
|
||||
...mode !== undefined ? { mode } : {},
|
||||
...workspaceRoot !== undefined ? { workspaceRoot } : {},
|
||||
})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
return { ctx, bash, calls }
|
||||
@@ -86,14 +97,14 @@ describe('the provider hand-off', () => {
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
})
|
||||
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to cwd when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write', cwd: tmpdir() })
|
||||
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
|
||||
const { bash, calls } = await setup({ mode: 'workspace-write' })
|
||||
const result = await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(tmpdir()) })
|
||||
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) })
|
||||
})
|
||||
|
||||
it('an explicit workspaceRoot wins over cwd', async () => {
|
||||
it('an explicit workspaceRoot on the policy wins', async () => {
|
||||
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
|
||||
await bash.run(bash.resolve({ command: 'true' }))
|
||||
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
@@ -43,7 +44,8 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
|
||||
ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
|
||||
await ctx.plugin(SandboxBashExecutor, { mode, cwd: workspace, workspaceRoot: workspace, timeoutMs: 30_000 })
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
|
||||
return ctx.bash as SandboxBashExecutor
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
@@ -26,6 +23,9 @@
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
|
||||
@@ -24,13 +24,11 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
|
||||
|
||||
export { BashTaskId, OwnerToken } from './types.ts'
|
||||
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
|
||||
export type {
|
||||
BashExecRequest,
|
||||
BashExecSpec,
|
||||
|
||||
@@ -130,7 +130,7 @@ export interface BashExecRequest {
|
||||
* consumer sets it only from an explicit policy source — an
|
||||
* `'allowed-once'` grant a human just issued through `ctx.approval` (the
|
||||
* escalation flow in the sandbox RFC § Escalation, which outranks), or the
|
||||
* session's standing override folded from its own `bash/sandbox-mode`
|
||||
* session's standing override folded from its own `sandbox/mode`
|
||||
* events (the sandbox RFC § Per-session mode switching — the user's recorded per-session
|
||||
* choice). A sandboxing executor confines THIS call under the given mode;
|
||||
* a non-sandboxing executor carries the field and confines nothing (the
|
||||
|
||||
@@ -19,9 +19,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
@@ -41,6 +41,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
* that the composition cannot honor.
|
||||
*
|
||||
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
|
||||
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
|
||||
* standing sandbox-mode override — the `sandbox/mode` event fold from
|
||||
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
|
||||
* call is stamped `escalation grant > session override > executor default`.
|
||||
* The prompt deliberately does NOT state the mode and no switch is narrated:
|
||||
@@ -60,14 +60,21 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
// Side-effect type import: declaration-merges `ctx.approval`, consumed
|
||||
// opportunistically by the escalation gate (`ctx.get('approval')` — the seam
|
||||
// stays optional at runtime, same pattern as dsh-tools' ask routing).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import {
|
||||
ESCALATION_TARGETS,
|
||||
approveEscalation,
|
||||
escalationHintMarker,
|
||||
sandboxDenialMarker,
|
||||
validateEscalationArgs,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
export const name = 'tool-bash'
|
||||
@@ -93,15 +100,9 @@ function validateBashArgs(args: BashToolArgs): void {
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
if (args.sandbox_permissions !== undefined && args.justification === undefined) {
|
||||
throw new Error('invalid escalation: sandbox_permissions requires a justification')
|
||||
}
|
||||
if (args.justification !== undefined && args.sandbox_permissions === undefined) {
|
||||
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
|
||||
}
|
||||
if (args.justification !== undefined && args.justification.trim().length === 0) {
|
||||
throw new Error('invalid justification: expected a non-empty sentence')
|
||||
}
|
||||
// The escalation pairing (sandbox_permissions ⇔ justification, non-empty) is
|
||||
// the shared rule both enforcing families validate identically.
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,27 +133,6 @@ interface BashToolArgs {
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The strictly-wider table: what a call whose effective mode is the key may
|
||||
* escalate TO. Checked at EXECUTION, never baked into the schema — the
|
||||
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
|
||||
* registry-global while the effective mode is per-call truth.
|
||||
*/
|
||||
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 executor confines: cutting the enum down to the modes
|
||||
* wider than the executor'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).
|
||||
*/
|
||||
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The bash tool's static description. The base text is byte-stable regardless
|
||||
* of composition (it is part of the pinned snapshot header); the escalation
|
||||
@@ -222,13 +202,13 @@ export function renderResult(
|
||||
// stays the LAST line (exitStatus() anchors its parse there). Denial is a
|
||||
// reported fact like timeout: the model decides how to react.
|
||||
if (result.sandbox?.denied) {
|
||||
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
||||
markers.push(sandboxDenialMarker(result.sandbox.mode))
|
||||
// The same-turn nudge lives at the decision point: only when this
|
||||
// composition advertises the fields (a lever is never hinted that the
|
||||
// schema does not offer), and inside the sandbox marker family so the
|
||||
// exit-code marker stays the last line.
|
||||
if (escalationModes.length > 0) {
|
||||
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
|
||||
markers.push(escalationHintMarker('command'))
|
||||
}
|
||||
}
|
||||
// Timeout is reported independently of how the process actually ended: a
|
||||
@@ -482,7 +462,7 @@ export function apply(ctx: Context): void {
|
||||
|
||||
/**
|
||||
* The session's standing mode override for an ordinary (non-escalating)
|
||||
* call: the `bash/sandbox-mode` fold of the calling agent's log, stamped
|
||||
* call: the `sandbox/mode` fold of the calling agent's log, stamped
|
||||
* onto the request so EXECUTION follows the same effective mode the prompt
|
||||
* section states. Weakest precedence — an escalation grant (freshly
|
||||
* approved for exactly this call) outranks it, and without either the
|
||||
@@ -495,58 +475,30 @@ export function apply(ctx: Context): void {
|
||||
|
||||
/**
|
||||
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
|
||||
* anything executes. Returns the granted mode to stamp onto the bash
|
||||
* request; throws the distinct fail-closed text for every other path (no
|
||||
* service composed, an agent-less execution, a rejection, a cancellation,
|
||||
* an unanswerable ask) — the registry turns the throw into this call's
|
||||
* isError result, and nothing has run. The seam is consumed
|
||||
* opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a
|
||||
* deployment without it degrades per call, never at registration.
|
||||
* anything executes, delegating the shared fail-closed sequence (strict
|
||||
* widening, channel resolution, outcome mapping) to
|
||||
* {@link approveEscalation}. This tool contributes only the composition
|
||||
* guard (the fields are unadvertised without a sandboxing executor, yet
|
||||
* schema validation checks advertised keys only, so an unadvertised
|
||||
* `sandbox_permissions` still reaches execute) and the channel closure over
|
||||
* `ctx.approval` — consumed opportunistically (`ctx.get`, the dsh-tools
|
||||
* ask-routing pattern) so a deployment without it degrades per call.
|
||||
*/
|
||||
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
// Schema validation only checks ADVERTISED keys, so an unadvertised
|
||||
// `sandbox_permissions` (no sandboxing executor) still reaches execute — reject it here so a
|
||||
// human is never prompted to "escalate" a sandbox that is not there. When
|
||||
// the fields ARE advertised, the registry's SchemaSpec enum has already
|
||||
// pinned `mode` to this ladder for every caller.
|
||||
const approveBashEscalation = (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
// Strict widening is an EXECUTION check against the call's effective
|
||||
// mode — session override ?? executor default, the same fold ordinary
|
||||
// calls are stamped with — deliberately not a schema constraint (the
|
||||
// enum is the closed target vocabulary; the effective mode is per-call
|
||||
// truth). A non-widening request fails closed here and never prompts a
|
||||
// human.
|
||||
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
|
||||
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`)
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
|
||||
}
|
||||
if (exec.agent === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
|
||||
}
|
||||
const outcome = await approval.request({
|
||||
agent: exec.agent,
|
||||
toolName: 'bash',
|
||||
callId: exec.callId,
|
||||
// Self-contained for the audit trail: approval/asked stores this
|
||||
// reason, and the target mode is part of the grant's identity.
|
||||
reason: `escalate sandbox to ${mode}: ${justification}`,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
// The SchemaSpec enum already pinned `mode` to the closed target
|
||||
// vocabulary; the per-call check above proved it is strictly wider.
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
case 'rejected': throw new Error(`the user rejected escalating this command 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, 'ApprovalOutcome')
|
||||
}
|
||||
return approveEscalation(
|
||||
{ requestedMode: mode, justification, effectiveMode, subject: 'command' },
|
||||
{
|
||||
approver: ctx.get('approval'),
|
||||
agent: exec.agent,
|
||||
callId: exec.callId,
|
||||
toolName: 'bash',
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -589,7 +541,7 @@ export function apply(ctx: Context): void {
|
||||
// An ordinary call carries the session's standing override instead —
|
||||
// grant > session override > executor default (see sessionOverride).
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
? await approveBashEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: sessionOverride(exec)
|
||||
// Default the workdir to the calling agent's session cwd so each ACP
|
||||
// session runs in its own workspace (see resolveWorkdir); an explicit
|
||||
@@ -649,9 +601,9 @@ export function apply(ctx: Context): void {
|
||||
// hint). Background denials are only classifiable once the task
|
||||
// settles (the classifier needs the whole stderr), so the marker
|
||||
// rides every read that sees the settled task.
|
||||
text += `\n[sandbox: file access denied under ${read.task.sandbox.mode} mode]`
|
||||
text += `\n${sandboxDenialMarker(read.task.sandbox.mode)}`
|
||||
if (escalationModes.length > 0) {
|
||||
text += '\n[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]'
|
||||
text += `\n${escalationHintMarker('command')}`
|
||||
}
|
||||
}
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { BashExecutor, BashTaskId, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -15,6 +15,7 @@ import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import { SandboxProvider } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxPolicyService, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
|
||||
@@ -1040,6 +1041,7 @@ describe('sandbox rendering', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
@@ -1106,6 +1108,7 @@ describe('sandbox rendering', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(FakeProvider)
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
@@ -1126,6 +1129,7 @@ describe('sandbox rendering', () => {
|
||||
runnerCommand: ['bash', '-c', `printf '${signature}\\n' >&2; exit 125`, 'custom-runner'],
|
||||
runnerFailureSignatures: [signature],
|
||||
})
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
@@ -1144,6 +1148,7 @@ describe('sandbox rendering', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxPolicyService, {})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
@@ -1167,7 +1172,8 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...mode !== undefined ? { mode } : {} })
|
||||
await ctx.plugin(SandboxPolicyService, mode !== undefined ? { mode } : {})
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
const bash = ctx.bash as SandboxBashExecutor
|
||||
bash.internals = { spillDir }
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService, opts.policy !== undefined ? { policy: opts.policy } : {})
|
||||
@@ -1381,7 +1387,7 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
|
||||
describe('per-session sandbox mode (the sandbox/mode fold)', () => {
|
||||
/** Compose the real sandbox stack (passthrough runner) at a given default mode. */
|
||||
async function setupModal(mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'read-only', opts: { approval?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
@@ -1389,7 +1395,8 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(LocalSandboxProvider, PASSTHROUGH_RUNNER_CONFIG)
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, mode })
|
||||
await ctx.plugin(SandboxPolicyService, { mode })
|
||||
await ctx.plugin(SandboxBashExecutor, { graceMs: 200 })
|
||||
;(ctx.bash as SandboxBashExecutor).internals = { spillDir }
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(ToolBash)
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
@@ -34,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -121,8 +121,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>',
|
||||
'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>',
|
||||
'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>',
|
||||
'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>',
|
||||
'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>',
|
||||
'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsWriteOutcome>',
|
||||
'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsEditOutcome>',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -151,6 +151,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
'abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv',
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sandboxPolicy',
|
||||
summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).',
|
||||
methods: [],
|
||||
},
|
||||
{
|
||||
key: 'sessionPersistence',
|
||||
summary: 'Abstract durable session-persistence service.',
|
||||
|
||||
@@ -6,10 +6,11 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
|
||||
|---|---|---|
|
||||
| `fs/` | Provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` policy events | `ctx.fs` |
|
||||
| `fs-local/` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
|
||||
| `fs-sandbox/` | Sandbox-enforcing `FileSystem`: extends `fs-local` and fences write/edit by the per-call sandbox mode (read-only denies, workspace-write contains to the workspace + temp roots), reads pass through | (registers `ctx.fs`) |
|
||||
| `fs-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |
|
||||
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
|
||||
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); advertises the sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas — `fs-sandbox` is the first such replacement (an in-process path fence over the shared sandbox mode; see [the cross-family fs sandbox RFC](../../docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose.
|
||||
|
||||
## No timeouts on file IO
|
||||
|
||||
|
||||
19
packages/fs/fs-sandbox/README.md
Normal file
19
packages/fs/fs-sandbox/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# dsh-fs-sandbox — the sandbox-enforcing filesystem backend
|
||||
|
||||
`SandboxedFileSystem` extends [`LocalFileSystem`](../fs-local/README.md) and registers as `ctx.fs`. It inherits every text-storage mechanic verbatim (resolve, stat, read/stream, list, the atomic write, the read-match-write edit critical section) and adds only a per-call MODE fence on `writeText`/`editText`. Reads always pass through — every mode permits reading.
|
||||
|
||||
Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/README.md), is the whole swap; the model-facing tools (`dsh-tool-fs`) are untouched. Injects `sandboxPolicy` for the default mode and the `workspace-write` boundary root — the SAME policy home bash reads, so the two families never confine to different roots.
|
||||
|
||||
## The fence
|
||||
|
||||
The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default:
|
||||
|
||||
- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`.
|
||||
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
|
||||
- `danger-full-access` — delegates unfenced.
|
||||
|
||||
## Threat model: a policy fence, not a kernel boundary
|
||||
|
||||
The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job ([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)). The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs `openat2`-class primitives not worth their portability cost here.
|
||||
|
||||
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
|
||||
38
packages/fs/fs-sandbox/package.json
Normal file
38
packages/fs/fs-sandbox/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-fs-sandbox",
|
||||
"description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through",
|
||||
"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-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-fs-local": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
155
packages/fs/fs-sandbox/src/index.ts
Normal file
155
packages/fs/fs-sandbox/src/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* `SandboxedFileSystem`: the sandbox-enforcing implementation of the
|
||||
* `@deepseek-ai/dsh-fs` provider seam. It extends `LocalFileSystem` so all
|
||||
* text-storage mechanics — resolve, stat, read/stream, list, the atomic
|
||||
* write and the read-match-write edit critical section — are the local
|
||||
* implementation's, verbatim; this package adds only the per-call MODE fence
|
||||
* on the two mutations. Reads pass through untouched: every mode permits
|
||||
* reading.
|
||||
*
|
||||
* The fence is a policy check in TRUSTED code over a MODEL-CONTROLLED path,
|
||||
* NOT a kernel boundary — the operations are the seam's own (open, rename),
|
||||
* and only the target path is untrusted, so canonicalize-then-contain is the
|
||||
* complete answer to this surface. Kernel-grade isolation of untrusted CODE
|
||||
* stays `ctx.bash`'s job (`@deepseek-ai/dsh-bash-sandbox`). This mirrors the
|
||||
* `code-runtime` stance: containment, not a security boundary. The residual
|
||||
* TOCTOU (an ancestor symlink swapped between the containment re-check and the
|
||||
* syscall) is narrowed by re-canonicalizing immediately before delegating and
|
||||
* is accepted for this threat model.
|
||||
*
|
||||
* Per-call mode: `read-only` denies every mutation; `workspace-write` allows a
|
||||
* mutation only when the target canonicalizes under the workspace root or a
|
||||
* platform temp area (the SAME writable-root set the Seatbelt profile grants,
|
||||
* derived from the one `writableRoots` function so bash and fs cannot drift);
|
||||
* `danger-full-access` delegates unfenced. A denial throws the structured
|
||||
* `FS_SANDBOX_DENIED` — no text inference is needed (unlike bash's kernel
|
||||
* stderr), because an in-process fence knows exactly what it refused. The
|
||||
* escalation retry lives in the tool layer (`@deepseek-ai/dsh-tool-fs`),
|
||||
* exactly as bash's does.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-fs-sandbox
|
||||
*/
|
||||
|
||||
import { sep } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent, FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
|
||||
/**
|
||||
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
|
||||
* base for relative paths). The sandbox default (mode + `workspace-write`
|
||||
* boundary root) is NOT here — it lives on `ctx.sandboxPolicy`, the one home
|
||||
* both enforcing families share.
|
||||
*/
|
||||
export type Config = LocalConfig
|
||||
|
||||
/** Whether `path` is `root` itself or lies beneath it (both already canonical). */
|
||||
function isUnder(path: string, root: string): boolean {
|
||||
if (path === root) return true
|
||||
const prefix = root.endsWith(sep) ? root : root + sep
|
||||
return path.startsWith(prefix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
|
||||
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
|
||||
* swap — the model-facing tools are untouched). Its configured default mode is
|
||||
* the fallback exposed by {@link sandboxMode}; `dsh-tool-fs` folds a session's
|
||||
* `sandbox/mode` override and stamps the effective mode onto each mutation,
|
||||
* while an approved escalation may stamp a strictly wider mode for one call.
|
||||
*/
|
||||
export class SandboxedFileSystem extends LocalFileSystem {
|
||||
static inject = ['sandboxPolicy']
|
||||
|
||||
private readonly defaultMode: SandboxMode
|
||||
/**
|
||||
* The canonical roots a `workspace-write` mutation may land under, computed
|
||||
* once (the workspace root and platform temp areas are fixed for the
|
||||
* provider's lifetime): the same set {@link writableRoots} gives every
|
||||
* enforcement dialect, so the fs fence and the bash runner agree.
|
||||
*/
|
||||
private readonly writableRoots: string[]
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, config)
|
||||
this.defaultMode = ctx.sandboxPolicy.defaultMode
|
||||
this.writableRoots = writableRoots({ mode: 'workspace-write', workspaceRoot: ctx.sandboxPolicy.workspaceRoot })
|
||||
}
|
||||
|
||||
/** The deployment default mode — the capability fact the tool layer reads to advertise escalation. */
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return this.defaultMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Fence the write by the per-call mode, then delegate to the inherited
|
||||
* atomic write. See {@link assertWritable}.
|
||||
* @param target - the resolved target to write.
|
||||
* @param content - the full new file content.
|
||||
* @param expected - the write intent guarding the write; omit for unconditional.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @param sandboxMode - the per-call mode; omit to use the deployment default.
|
||||
* @returns the write outcome from the inherited backend.
|
||||
*/
|
||||
override async writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsWriteOutcome> {
|
||||
await this.assertWritable(target, sandboxMode)
|
||||
return super.writeText(target, content, expected, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fence the edit by the per-call mode, then delegate to the inherited
|
||||
* atomic edit. See {@link assertWritable}.
|
||||
* @param target - the resolved target to edit.
|
||||
* @param edit - the literal search/replace request.
|
||||
* @param expected - the version guard; omit for an unconditional edit.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @param sandboxMode - the per-call mode; omit to use the deployment default.
|
||||
* @returns the edit outcome from the inherited backend.
|
||||
*/
|
||||
override async editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsEditOutcome> {
|
||||
await this.assertWritable(target, sandboxMode)
|
||||
return super.editText(target, edit, expected, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the per-call mode against `target` before delegating the mutation.
|
||||
* `read-only` denies; `workspace-write` re-canonicalizes the target NOW
|
||||
* (`resolve` realpaths the deepest existing ancestor, reflecting a
|
||||
* concurrently swapped symlink) and requires containment under a writable
|
||||
* root; `danger-full-access` allows. Throws the structured
|
||||
* `FS_SANDBOX_DENIED` on refusal — the tool layer maps it to the model-facing
|
||||
* `[sandbox: …]` marker and the escalation hint.
|
||||
*/
|
||||
private async assertWritable(target: FsTarget, sandboxMode?: SandboxMode): Promise<void> {
|
||||
const mode = sandboxMode ?? this.defaultMode
|
||||
if (mode === 'danger-full-access') return
|
||||
if (mode === 'read-only') {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
|
||||
}
|
||||
// workspace-write: containment on the FRESH canonical path (catches a
|
||||
// symlink ancestor swapped since the tool resolved this target).
|
||||
const fresh = await this.resolve(target.displayPath)
|
||||
if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) {
|
||||
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SandboxedFileSystem
|
||||
224
packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
Normal file
224
packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Tests for the sandbox-enforcing filesystem backend: the per-call mode fence
|
||||
* on write/edit (read-only denies, workspace-write contains, danger-full-access
|
||||
* passes through), reads always passing through, the capability fact, and the
|
||||
* containment matrix — `..` traversal, absolute paths outside, and symlink
|
||||
* escapes (a symlinked directory inside the workspace pointing out, and a new
|
||||
* file created under one). The fence is exercised on a real filesystem: a
|
||||
* denied write leaves no file on disk.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsTarget } from '@deepseek-ai/dsh-fs'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
|
||||
|
||||
let base: string
|
||||
let workspace: string
|
||||
let outside: string
|
||||
let ctx: Context
|
||||
let fs: SandboxedFileSystem
|
||||
let fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
|
||||
async function boot(mode: SandboxMode): Promise<void> {
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
|
||||
fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
|
||||
fs = ctx.fs as SandboxedFileSystem
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Base under HOME, deliberately NOT tmpdir: `workspace-write` grants /tmp and
|
||||
// os.tmpdir() (parity with the bash runner), so an "outside" dir under tmpdir
|
||||
// would be legitimately writable. Sibling dirs under HOME are outside every
|
||||
// grant, so containment failures are real denials. (The bwrap e2e roots its
|
||||
// workspaces under HOME for the same reason.)
|
||||
base = await mkdtemp(join(homedir(), '.dsh-fssbx-'))
|
||||
workspace = join(base, 'ws')
|
||||
outside = join(base, 'out')
|
||||
await mkdir(workspace)
|
||||
await mkdir(outside)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fiber?.dispose()
|
||||
await rm(base, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Resolve a path through the backend and return its target. */
|
||||
function target(path: string): Promise<FsTarget> {
|
||||
return fs.resolve(path)
|
||||
}
|
||||
|
||||
describe('the capability fact', () => {
|
||||
it('reports the deployment default mode (what the tool layer advertises against)', async () => {
|
||||
await boot('workspace-write')
|
||||
expect(fs.sandboxMode).toBe('workspace-write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('read-only', () => {
|
||||
beforeEach(() => boot('read-only'))
|
||||
|
||||
it('denies write, leaving no file on disk', async () => {
|
||||
const path = join(workspace, 'denied.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(path)).toBe(false)
|
||||
})
|
||||
|
||||
it('denies edit of an existing file (the content is unchanged)', async () => {
|
||||
const path = join(workspace, 'file.txt')
|
||||
await writeFile(path, 'original')
|
||||
await expect(fs.editText(await target(path), { oldString: 'original', newString: 'changed', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(await readFile(path, 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
it('allows reads (every mode permits reading)', async () => {
|
||||
const path = join(workspace, 'readable.txt')
|
||||
await writeFile(path, 'hello')
|
||||
expect(await fs.readText(await target(path))).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace-write containment', () => {
|
||||
beforeEach(() => boot('workspace-write'))
|
||||
|
||||
it('a write under the workspace lands', async () => {
|
||||
const path = join(workspace, 'nested', 'ok.txt')
|
||||
const outcome = await fs.writeText(await target(path), 'inside')
|
||||
expect(outcome.operation).toBe('create')
|
||||
expect(await readFile(path, 'utf8')).toBe('inside')
|
||||
})
|
||||
|
||||
it('a write to the platform temp area lands (parity with the bash runner grant)', async () => {
|
||||
const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt')
|
||||
await fs.writeText(await target(path), 'temp')
|
||||
expect(await readFile(path, 'utf8')).toBe('temp')
|
||||
})
|
||||
|
||||
it('an absolute path outside the workspace is denied, no file created', async () => {
|
||||
const path = join(outside, 'escape.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(path)).toBe(false)
|
||||
})
|
||||
|
||||
it('a `..` traversal out of the workspace is denied', async () => {
|
||||
const path = join(workspace, '..', 'sibling-escape.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(join(workspace, '..', 'sibling-escape.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('a symlinked directory inside the workspace pointing OUT is denied (canonicalized before containment)', async () => {
|
||||
// workspace/link -> outside ; writing workspace/link/f.txt would land in outside/f.txt.
|
||||
await symlink(outside, join(workspace, 'link'))
|
||||
const path = join(workspace, 'link', 'f.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(join(outside, 'f.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('a NEW file created under a symlinked-out directory is denied (deepest-ancestor realpath)', async () => {
|
||||
await symlink(outside, join(workspace, 'link'))
|
||||
const path = join(workspace, 'link', 'newdir', 'deep.txt')
|
||||
await expect(fs.writeText(await target(path), 'x')).rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(join(outside, 'newdir'))).toBe(false)
|
||||
})
|
||||
|
||||
it('an edit outside the workspace is denied; the original is untouched', async () => {
|
||||
const path = join(outside, 'file.txt')
|
||||
await writeFile(path, 'original')
|
||||
await expect(fs.editText(await target(path), { oldString: 'original', newString: 'x', replaceAll: false }))
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(await readFile(path, 'utf8')).toBe('original')
|
||||
})
|
||||
|
||||
it('an edit inside the workspace lands', async () => {
|
||||
const path = join(workspace, 'edit.txt')
|
||||
await writeFile(path, 'original')
|
||||
const outcome = await fs.editText(await target(path), { oldString: 'original', newString: 'changed', replaceAll: false })
|
||||
expect(outcome.after).toBe('changed')
|
||||
expect(await readFile(path, 'utf8')).toBe('changed')
|
||||
})
|
||||
|
||||
it('the workspace root itself passes the fence (path equal to a writable root), failing only on file type', async () => {
|
||||
// isUnder's path-equals-root branch: the fence allows the root, and the
|
||||
// write then fails because the root is a directory, not a regular file.
|
||||
await expect(fs.writeText(await target(workspace), 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace-write with the filesystem root as the workspace (a root ending in the path separator)', () => {
|
||||
it('grants writes anywhere: containment against `/` allows any absolute path', async () => {
|
||||
// A degenerate but valid config — workspaceRoot '/'. It exercises isUnder's
|
||||
// separator-suffixed-root branch: `/` already ends in the separator, so the
|
||||
// prefix stays `/` and every absolute path is contained.
|
||||
const rootCtx = new Context()
|
||||
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/' })
|
||||
const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace })
|
||||
const rootFs = rootCtx.fs as SandboxedFileSystem
|
||||
try {
|
||||
const path = join(base, 'anywhere.txt') // under HOME, outside /tmp — allowed only via the `/` root
|
||||
await rootFs.writeText(await rootFs.resolve(path), 'anywhere')
|
||||
expect(await readFile(path, 'utf8')).toBe('anywhere')
|
||||
} finally {
|
||||
await rootFiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('danger-full-access', () => {
|
||||
beforeEach(() => boot('danger-full-access'))
|
||||
|
||||
it('writes anywhere, unfenced', async () => {
|
||||
const path = join(outside, 'free.txt')
|
||||
await fs.writeText(await target(path), 'free')
|
||||
expect(await readFile(path, 'utf8')).toBe('free')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the per-call mode override (escalation)', () => {
|
||||
it('a workspace-write stamp on a read-only default lets a contained write land for that call only', async () => {
|
||||
await boot('read-only')
|
||||
const path = join(workspace, 'escalated.txt')
|
||||
// Default read-only would deny; the per-call workspace-write stamp allows it (contained).
|
||||
await fs.writeText(await target(path), 'granted', undefined, undefined, 'workspace-write')
|
||||
expect(await readFile(path, 'utf8')).toBe('granted')
|
||||
// A neighboring plain call still runs under the read-only default.
|
||||
await expect(fs.writeText(await target(join(workspace, 'plain.txt')), 'x'))
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
})
|
||||
|
||||
it('a danger-full-access stamp bypasses the fence for that call', async () => {
|
||||
await boot('read-only')
|
||||
const path = join(outside, 'granted-full.txt')
|
||||
await fs.writeText(await target(path), 'full', undefined, undefined, 'danger-full-access')
|
||||
expect(await readFile(path, 'utf8')).toBe('full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration and HMR safety', () => {
|
||||
it('registers as ctx.fs and unregisters cleanly from a child fiber', async () => {
|
||||
await boot('workspace-write')
|
||||
expect(ctx.fs).toBeInstanceOf(SandboxedFileSystem)
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('fs')).toBeUndefined()
|
||||
// Re-mount below the disposed one to prove no lingering registration.
|
||||
fiber = await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
|
||||
expect(ctx.fs).toBeInstanceOf(SandboxedFileSystem)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FsError identity', () => {
|
||||
it('the denial is a structured FsError distinct from a host permission error', async () => {
|
||||
await boot('read-only')
|
||||
const error = await fs.writeText(await target(join(workspace, 'x.txt')), 'x').catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(FsError)
|
||||
expect((error as FsError).code).toBe('FS_SANDBOX_DENIED')
|
||||
})
|
||||
})
|
||||
30
packages/fs/fs-sandbox/tsconfig.json
Normal file
30
packages/fs/fs-sandbox/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../fs"
|
||||
},
|
||||
{
|
||||
"path": "../fs-local"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,11 +24,13 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {
|
||||
FsDirEntry,
|
||||
FsEditOutcome,
|
||||
@@ -174,6 +175,22 @@ export abstract class FileSystem extends Service {
|
||||
super(ctx, 'fs')
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox mode this backend enforces on mutations BY DEFAULT, or
|
||||
* `undefined` when it does not confine at all — the capability fact the tool
|
||||
* layer reads to advertise the escalation fields honestly (mirrors
|
||||
* `BashExecutor.sandboxMode`). The base class and the bare local backend
|
||||
* report `undefined`; a sandboxing backend (`@deepseek-ai/dsh-fs-sandbox`)
|
||||
* overrides it with the deployment default. A session override may make the
|
||||
* effective mode narrower or wider, so strict escalation widening is checked
|
||||
* per call rather than encoded in this default-relative fact.
|
||||
* @returns the configured default mode of a sandboxing backend; `undefined`
|
||||
* for a backend that never confines.
|
||||
*/
|
||||
get sandboxMode(): SandboxMode | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May
|
||||
* perform I/O (a remote/sandboxed backend may need a round-trip to map a path
|
||||
@@ -238,9 +255,18 @@ export abstract class FileSystem extends Service {
|
||||
* @param content - the full new file content.
|
||||
* @param expected - the write intent guarding the write; omit for unconditional.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @param sandboxMode - the per-call sandbox mode this write runs under; a
|
||||
* sandboxing backend fences the write by it, the bare backend ignores it.
|
||||
* Omit to leave the backend its own default.
|
||||
* @returns the outcome, including the version the write produced.
|
||||
*/
|
||||
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
abstract writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsWriteOutcome>
|
||||
|
||||
/**
|
||||
* Apply a literal edit to an existing UTF-8 text file. When `expected` is
|
||||
@@ -252,9 +278,18 @@ export abstract class FileSystem extends Service {
|
||||
* @param edit - the literal search/replace request.
|
||||
* @param expected - the version guard; omit for an unconditional edit.
|
||||
* @param signal - aborts before the atomic rename takes effect.
|
||||
* @param sandboxMode - the per-call sandbox mode this edit runs under; a
|
||||
* sandboxing backend fences the edit by it, the bare backend ignores it.
|
||||
* Omit to leave the backend its own default.
|
||||
* @returns the outcome, including the version the edit produced.
|
||||
*/
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
abstract editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsEditOutcome>
|
||||
}
|
||||
|
||||
export default FileSystem
|
||||
|
||||
@@ -173,6 +173,7 @@ export type FsErrorCode =
|
||||
| 'FS_NOT_TEXT'
|
||||
| 'FS_NOT_REGULAR_FILE'
|
||||
| 'FS_PERMISSION_DENIED'
|
||||
| 'FS_SANDBOX_DENIED'
|
||||
| 'FS_IO_ERROR'
|
||||
| 'FS_STALE_VERSION'
|
||||
| 'FS_NOT_OBSERVED'
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../../llm/llm" }
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../sandbox/sandbox" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,9 +28,12 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-fs": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -41,9 +44,12 @@
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
/** Validated `edit` arguments after defaulting. */
|
||||
interface EditInput {
|
||||
@@ -29,6 +30,20 @@ interface EditInput {
|
||||
replaceAll: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The `edit` tool's validated argument shape: the base parameters plus the two
|
||||
* escalation fields, advertised only under a confining `ctx.fs` (absent from
|
||||
* the schema otherwise, so the validator rejects them before `execute`).
|
||||
*/
|
||||
interface EditToolArgs {
|
||||
file_path: string
|
||||
old_string: string
|
||||
new_string: string
|
||||
replace_all?: boolean
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: a non-blank
|
||||
* `file_path`, a non-empty `old_string`, and `old_string !== new_string`
|
||||
@@ -63,8 +78,9 @@ export function formatEditOutput(displayPath: string, replaceAll: boolean): stri
|
||||
/**
|
||||
* Register the `edit` tool and its system-prompt guidance.
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
||||
* @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping).
|
||||
*/
|
||||
export function applyEditTool(ctx: Context): void {
|
||||
export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:edit',
|
||||
order: 102,
|
||||
@@ -79,21 +95,32 @@ export function applyEditTool(ctx: Context): void {
|
||||
old_string: { type: 'string', required: true, description: 'Literal text to replace. Must match exactly.' },
|
||||
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
|
||||
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
|
||||
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
async execute(args: EditToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseEditArgs(args)
|
||||
// Resolve the per-call sandbox mode (escalation grant > session override
|
||||
// > backend default) BEFORE anything executes.
|
||||
const sandboxMode = await sandbox.stampMode('edit', args, exec)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin returns { version: vObserved } or
|
||||
// throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
|
||||
// No stat — the bare default never manufactures a version basis.
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
intent,
|
||||
exec.signal,
|
||||
)
|
||||
let outcome
|
||||
try {
|
||||
outcome = await ctx.fs.editText(
|
||||
target,
|
||||
{ oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
|
||||
intent,
|
||||
exec.signal,
|
||||
sandboxMode,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through.
|
||||
throw sandbox.mapError(error, sandboxMode)
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// The result-time applied-hunk diff (before→after with context lines). An
|
||||
|
||||
@@ -24,10 +24,12 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
|
||||
import { applyWriteTool } from './write.ts'
|
||||
import { applyEditTool } from './edit.ts'
|
||||
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
|
||||
import { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
|
||||
export type { ReadToolCaps } from './read.ts'
|
||||
@@ -37,6 +39,8 @@ export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } f
|
||||
export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts'
|
||||
export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts'
|
||||
export type { FsDiffMeta } from './diff.ts'
|
||||
export { FsSandboxSurface } from './sandbox.ts'
|
||||
export type { EscalationSchemaFields, FsEscalationArgs } from './sandbox.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'tool-fs'
|
||||
@@ -87,6 +91,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
maxBytes: resolved.readMaxBytes,
|
||||
streamMinSize: resolved.readStreamMinSize,
|
||||
})
|
||||
applyWriteTool(ctx)
|
||||
applyEditTool(ctx)
|
||||
// One escalation surface shared by both mutating tools: advertisement gating,
|
||||
// per-call mode stamping, and denial-marker mapping, all keyed off whether
|
||||
// the mounted ctx.fs confines (ctx.fs.sandboxMode).
|
||||
const sandbox = new FsSandboxSurface(ctx)
|
||||
applyWriteTool(ctx, sandbox)
|
||||
applyEditTool(ctx, sandbox)
|
||||
}
|
||||
|
||||
132
packages/fs/tool-fs/src/sandbox.ts
Normal file
132
packages/fs/tool-fs/src/sandbox.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The sandbox-escalation surface shared by the `write` and `edit` tools: the
|
||||
* per-call mode stamp, the advertised escalation fields, and the denial-marker
|
||||
* mapping — all delegating the vocabulary and the fail-closed approval
|
||||
* sequence to `@deepseek-ai/dsh-sandbox` (the same pieces `@deepseek-ai/dsh-tool-bash`
|
||||
* uses), so bash and fs escalate identically. Built ONCE per plugin from
|
||||
* `ctx.fs.sandboxMode` (the capability fact — is a confining backend mounted?)
|
||||
* and shared by both mutating tools.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-fs/sandbox
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
|
||||
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** The two escalation arguments a mutating tool may carry (advertised only under a confining backend). */
|
||||
export interface FsEscalationArgs {
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/** The schema fields for the escalation arguments, spread into a tool's `parameters` when a confining backend is mounted. */
|
||||
export interface EscalationSchemaFields {
|
||||
sandbox_permissions: { type: 'string'; enum: string[]; description: string }
|
||||
justification: { type: 'string'; description: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* The filesystem escalation surface: advertisement gating, per-call mode
|
||||
* stamping (folding the session's `sandbox/mode` override), the one-approved
|
||||
* wider retry, and denial-marker mapping. A pure product of `ctx` at plugin
|
||||
* apply time.
|
||||
*/
|
||||
export class FsSandboxSurface {
|
||||
/** The escalation targets this composition advertises (`[]` when no confining backend is mounted). */
|
||||
readonly escalationModes: readonly SandboxMode[]
|
||||
/** The backend's default mode, or `undefined` when `ctx.fs` does not confine. */
|
||||
private readonly defaultMode: SandboxMode | undefined
|
||||
|
||||
constructor(private readonly ctx: Context) {
|
||||
this.defaultMode = ctx.fs.sandboxMode
|
||||
this.escalationModes = this.defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
}
|
||||
|
||||
/**
|
||||
* The escalation schema fields for a mutating tool's `parameters`. Call it
|
||||
* only under a confining backend (guard on {@link escalationModes}); the
|
||||
* enum pins the closed target vocabulary, the strict-wider check happens per
|
||||
* call at execution.
|
||||
* @returns the two escalation parameter specs.
|
||||
*/
|
||||
schemaFields(): EscalationSchemaFields {
|
||||
return {
|
||||
sandbox_permissions: {
|
||||
type: 'string',
|
||||
enum: [...this.escalationModes],
|
||||
description: 'The wider sandbox mode this file operation needs. Only valid as a one-shot retry '
|
||||
+ 'of an operation the sandbox just denied; requires justification and user approval.',
|
||||
},
|
||||
justification: {
|
||||
type: 'string',
|
||||
description: 'Required with sandbox_permissions: one sentence for the user explaining '
|
||||
+ 'why this exact file operation needs the wider access.',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's standing mode override for an ordinary (non-escalating)
|
||||
* call — the `sandbox/mode` fold of the calling agent's log. Undefined for a
|
||||
* non-confining backend and for agent-less callers.
|
||||
*/
|
||||
private sessionOverride(exec: ToolExecution): SandboxMode | undefined {
|
||||
if (this.defaultMode === undefined || exec.agent === undefined) return undefined
|
||||
return effectiveSandboxMode(exec.agent.session.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode to STAMP onto this mutation: an approved escalation grant (a
|
||||
* strictly wider retry resolved through `ctx.approval` before anything
|
||||
* executes), else the session's standing override, else `undefined` (the
|
||||
* backend applies its own default). Validates the escalation argument
|
||||
* pairing first.
|
||||
* @param toolName - the mutating tool's name, for the approval audit trail.
|
||||
* @param args - the call's escalation arguments.
|
||||
* @param exec - the tool-execution context (agent, callId, signal).
|
||||
* @returns the mode to pass to the mutation, or undefined for the backend default.
|
||||
*/
|
||||
async stampMode(toolName: string, args: FsEscalationArgs, exec: ToolExecution): Promise<SandboxMode | undefined> {
|
||||
validateEscalationArgs(args.sandbox_permissions, args.justification)
|
||||
if (args.sandbox_permissions === undefined || args.justification === undefined) {
|
||||
return this.sessionOverride(exec)
|
||||
}
|
||||
if (this.escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing filesystem to escalate)')
|
||||
}
|
||||
const effectiveMode = (this.sessionOverride(exec) ?? this.defaultMode) as SandboxMode
|
||||
return approveEscalation(
|
||||
{ requestedMode: args.sandbox_permissions, justification: args.justification, effectiveMode, subject: 'operation' },
|
||||
{
|
||||
approver: this.ctx.get('approval'),
|
||||
agent: exec.agent,
|
||||
callId: exec.callId,
|
||||
toolName,
|
||||
...exec.signal ? { signal: exec.signal } : {},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a thrown provider error for the model: a `FS_SANDBOX_DENIED` becomes an
|
||||
* error whose text is the shared `[sandbox: …]` denial marker plus the
|
||||
* same-turn escalation hint, so a policy denial reads identically to bash's;
|
||||
* any other error passes through unchanged. A `FS_SANDBOX_DENIED` only arises
|
||||
* under a confining backend, which always advertises the escalation fields,
|
||||
* so the hint always applies here.
|
||||
* @param error - the error thrown by the mutation.
|
||||
* @param stampedMode - the mode stamped onto the call (names the mode in the marker).
|
||||
* @returns the error to throw — the marker error for a sandbox denial, else the original.
|
||||
*/
|
||||
mapError(error: unknown, stampedMode: SandboxMode | undefined): unknown {
|
||||
if (!(error instanceof FsError) || error.code !== 'FS_SANDBOX_DENIED') return error
|
||||
// A FS_SANDBOX_DENIED only arises under a confining backend, so defaultMode
|
||||
// (hence the resolved mode) is defined here.
|
||||
const mode = (stampedMode ?? this.defaultMode) as SandboxMode
|
||||
return new Error(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
import type { FsSandboxSurface } from './sandbox.ts'
|
||||
|
||||
/**
|
||||
* Validate value constraints the schema DSL can't express: only a non-blank
|
||||
@@ -47,11 +48,24 @@ ${verb} file
|
||||
</content>`
|
||||
}
|
||||
|
||||
/**
|
||||
* The `write` tool's validated argument shape: the base parameters plus the
|
||||
* two escalation fields, advertised only under a confining `ctx.fs` (absent
|
||||
* from the schema otherwise, so the validator rejects them before `execute`).
|
||||
*/
|
||||
interface WriteToolArgs {
|
||||
file_path: string
|
||||
content: string
|
||||
sandbox_permissions?: string
|
||||
justification?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `write` tool and its system-prompt guidance.
|
||||
* @param ctx - the plugin context; registrations are effects scoped to it, and execution uses its `fs` service.
|
||||
* @param sandbox - the shared sandbox-escalation surface (advertisement, mode stamping, denial mapping).
|
||||
*/
|
||||
export function applyWriteTool(ctx: Context): void {
|
||||
export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:write',
|
||||
order: 101,
|
||||
@@ -64,15 +78,27 @@ export function applyWriteTool(ctx: Context): void {
|
||||
parameters: {
|
||||
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
|
||||
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
|
||||
...sandbox.escalationModes.length > 0 ? sandbox.schemaFields() : {},
|
||||
},
|
||||
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
async execute(args: WriteToolArgs, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
|
||||
const input = parseWriteArgs(args)
|
||||
// Resolve the per-call sandbox mode (escalation grant > session override
|
||||
// > backend default) BEFORE anything executes; an escalating call
|
||||
// resolves approval here and throws its distinct text on any non-grant.
|
||||
const sandboxMode = await sandbox.stampMode('write', args, exec)
|
||||
const cwd = sessionCwd(exec)
|
||||
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
|
||||
// Single-slot decision: the policy plugin produces createIfAbsent/
|
||||
// replaceIfVersion; the bare default is undefined (unconditional). No stat.
|
||||
const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
|
||||
let outcome: FsWriteOutcome
|
||||
try {
|
||||
outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxMode)
|
||||
} catch (error: unknown) {
|
||||
// A sandbox denial becomes the shared [sandbox: …] marker (the model
|
||||
// recognizes it from bash); any other error passes through.
|
||||
throw sandbox.mapError(error, sandboxMode)
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens).
|
||||
ctx.emit('fs/observed', target, outcome.version, exec)
|
||||
// Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version
|
||||
|
||||
@@ -27,6 +27,8 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
|
||||
import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
|
||||
class FakeFs extends FileSystem {
|
||||
@@ -570,3 +572,163 @@ describe('read caps are plugin config', () => {
|
||||
expect('default' in ToolFs).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sandbox escalation surface (write/edit)', () => {
|
||||
/** A confining fake `ctx.fs`: reports a default mode, records the per-call mode stamped, and can arm a sandbox denial. */
|
||||
class SandboxingFakeFs extends FakeFs {
|
||||
stamped: (SandboxMode | undefined)[] = []
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'workspace-write'
|
||||
}
|
||||
override async writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteIntent,
|
||||
_signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsWriteOutcome> {
|
||||
this.stamped.push(sandboxMode)
|
||||
return super.writeText(target, content, expected)
|
||||
}
|
||||
override async editText(
|
||||
target: FsTarget,
|
||||
edit: FsEditRequest,
|
||||
expected?: { version: FsVersion },
|
||||
_signal?: AbortSignal,
|
||||
sandboxMode?: SandboxMode,
|
||||
): Promise<FsEditOutcome> {
|
||||
this.stamped.push(sandboxMode)
|
||||
return super.editText(target, edit, expected)
|
||||
}
|
||||
}
|
||||
|
||||
async function setupConfining(opts: { approval?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(SandboxingFakeFs)
|
||||
await ctx.plugin(FsPolicy)
|
||||
if (opts.approval === true) await ctx.plugin(ApprovalService)
|
||||
await ctx.plugin(ToolFs)
|
||||
return { ctx, fs: ctx.fs as SandboxingFakeFs }
|
||||
}
|
||||
|
||||
/** A fake agent whose session records appends (the approval audit surface), mid-turn, carrying the given events for the fold. */
|
||||
function escalationAgent(events: Array<{ type: string; data?: Record<string, unknown> }> = []): object {
|
||||
return {
|
||||
id: 'agent-fs-esc',
|
||||
session: {
|
||||
header: { version: 0, id: 'sess-fs-esc', createdAt: 0 },
|
||||
events: [{ type: 'turn/start' }, ...events],
|
||||
append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fsSchema(ctx: Context, name: 'write' | 'edit') {
|
||||
const schema = ctx.tools.schemas().find(s => s.name === name)
|
||||
if (!schema) throw new Error(`${name} tool not registered`)
|
||||
return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
|
||||
}
|
||||
|
||||
it('advertises no escalation fields under a non-confining backend', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(ctx.fs.sandboxMode).toBeUndefined()
|
||||
for (const name of ['write', 'edit'] as const) {
|
||||
const props = fsSchema(ctx, name).parameters.properties
|
||||
expect(props['sandbox_permissions']).toBeUndefined()
|
||||
expect(props['justification']).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('advertises the closed target vocabulary on write and edit under a confining backend', async () => {
|
||||
const { ctx } = await setupConfining()
|
||||
for (const name of ['write', 'edit'] as const) {
|
||||
const props = fsSchema(ctx, name).parameters.properties
|
||||
expect(props['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(props['justification']).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('a plain write stamps nothing (backend default) and no session override folds without one', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(fs.stamped).toEqual([undefined])
|
||||
})
|
||||
|
||||
it('a standing session override folds onto the stamp', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
|
||||
expect(fs.stamped).toEqual(['read-only'])
|
||||
})
|
||||
|
||||
it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
fs.rejectWith = new FsError('denied', 'FS_SANDBOX_DENIED')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('[sandbox: file access denied under workspace-write mode]')
|
||||
expect(text(result)).toContain('retry this exact operation once with sandbox_permissions')
|
||||
})
|
||||
|
||||
it('a non-FS_SANDBOX_DENIED provider error passes through unchanged', async () => {
|
||||
const { ctx, fs } = await setupConfining()
|
||||
fs.rejectWith = new FsError('boom', 'FS_IO_ERROR')
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('boom')
|
||||
expect(text(result)).not.toContain('[sandbox:')
|
||||
})
|
||||
|
||||
it('an approved escalation stamps the granted mode onto that write', async () => {
|
||||
const { ctx, fs } = await setupConfining({ approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve('allowed-once' as const))
|
||||
// Pass a signal so the escalation ask forwards it to the approval request
|
||||
// (the request rides the tool-execution abort signal).
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('call-fs-esc-grant'),
|
||||
name: 'write',
|
||||
arguments: { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' },
|
||||
agent: escalationAgent() as never,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(fs.stamped).toEqual(['danger-full-access'])
|
||||
})
|
||||
|
||||
it('a rejected escalation fails closed with its own text and never mutates', async () => {
|
||||
const { ctx, fs } = await setupConfining({ approval: true })
|
||||
ctx.on('approval/request', () => Promise.resolve('rejected' as const))
|
||||
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('the user rejected escalating this operation to "danger-full-access"')
|
||||
expect(fs.stamped).toEqual([])
|
||||
})
|
||||
|
||||
it('escalation without an approval service fails closed', async () => {
|
||||
const { ctx } = await setupConfining()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no approval service is composed')
|
||||
})
|
||||
|
||||
it('escalation with an approval service but no agent fails closed', async () => {
|
||||
const { ctx } = await setupConfining({ approval: true })
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no agent to route it through')
|
||||
})
|
||||
|
||||
it('rejects the escalation argument pairing (one field without the other)', async () => {
|
||||
const { ctx } = await setupConfining()
|
||||
const missing = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write' }, escalationAgent())
|
||||
expect(missing.isError).toBe(true)
|
||||
expect(text(missing)).toContain('sandbox_permissions requires a justification')
|
||||
})
|
||||
|
||||
it('sandbox_permissions under a non-confining backend fails closed (unadvertised field still reaches execute)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write', justification: 'why' }, escalationAgent())
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('not available in this composition')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
{ "path": "../../core/tools" },
|
||||
{ "path": "../../core/system-prompt" },
|
||||
{ "path": "../fs" },
|
||||
{ "path": "../fs-policy" }
|
||||
{ "path": "../fs-policy" },
|
||||
{ "path": "../../sandbox/sandbox" },
|
||||
{ "path": "../../sandbox/sandbox-policy" },
|
||||
{ "path": "../../ui/user-approval" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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](../../docs/rfc/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).
|
||||
|
||||
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 RFC's [cross-family fs sandbox](../../docs/rfc/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.
|
||||
|
||||
23
packages/sandbox/sandbox-policy/README.md
Normal file
23
packages/sandbox/sandbox-policy/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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](../../../docs/rfc/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](../../../docs/rfc/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.
|
||||
37
packages/sandbox/sandbox-policy/package.json
Normal file
37
packages/sandbox/sandbox-policy/package.json
Normal 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.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
84
packages/sandbox/sandbox-policy/src/index.ts
Normal file
84
packages/sandbox/sandbox-policy/src/index.ts
Normal 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
|
||||
@@ -1,18 +1,21 @@
|
||||
/**
|
||||
* 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 `bash/sandbox-mode` event on the session it applies to;
|
||||
* `effective = fold(events) ?? the executor's configured 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
|
||||
* prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`,
|
||||
* never from the event itself. EXECUTION honors the fold in the tool layer —
|
||||
* it stamps the effective mode onto each call's `BashExecRequest.sandboxMode`
|
||||
* (weakest-precedence: an escalation grant for the call outranks it) — the
|
||||
* executor itself stays a config-fixed default plus per-call overrides.
|
||||
* 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.
|
||||
*
|
||||
* @module dsh-bash/session-mode
|
||||
* 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'
|
||||
@@ -28,7 +31,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
* from position (an event after the log's last `request/header*` was a
|
||||
* runtime switch by the user; see the tool layer's narrator).
|
||||
*/
|
||||
'bash/sandbox-mode': { mode: SandboxMode }
|
||||
'sandbox/mode': { mode: SandboxMode }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,30 +39,30 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* The session's sandbox-mode override: the last `bash/sandbox-mode` event in
|
||||
* the log, or undefined when the session never switched (callers apply the
|
||||
* executor's configured default). The pure fold — resume needs no catch-up
|
||||
* machinery because replaying the log IS the state.
|
||||
* 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 === 'bash/sandbox-mode') return event.data.mode
|
||||
if (event.type === 'sandbox/mode') return event.data.mode
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a session's sandbox-mode override: appends exactly one
|
||||
* `bash/sandbox-mode` event — the switch IS its event; nothing mutates mode
|
||||
* state out of band. Takes effect on the session's next bash call and next
|
||||
* prompt assembly (the consumers fold on every read).
|
||||
* `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 bash call in this session runs
|
||||
* @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('bash/sandbox-mode', { mode })
|
||||
session.append('sandbox/mode', { mode })
|
||||
}
|
||||
67
packages/sandbox/sandbox-policy/tests/policy.spec.ts
Normal file
67
packages/sandbox/sandbox-policy/tests/policy.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
27
packages/sandbox/sandbox-policy/tsconfig.json
Normal file
27
packages/sandbox/sandbox-policy/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
189
packages/sandbox/sandbox/src/escalation.ts
Normal file
189
packages/sandbox/sandbox/src/escalation.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,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 a sandbox backend enforces on confined processes.
|
||||
*
|
||||
|
||||
51
packages/sandbox/sandbox/src/roots.ts
Normal file
51
packages/sandbox/sandbox/src/roots.ts
Normal 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))]
|
||||
}
|
||||
111
packages/sandbox/sandbox/tests/escalation.spec.ts
Normal file
111
packages/sandbox/sandbox/tests/escalation.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
39
packages/sandbox/sandbox/tests/roots.spec.ts
Normal file
39
packages/sandbox/sandbox/tests/roots.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -99,12 +99,12 @@ describe('acp bridge — session config options', () => {
|
||||
|
||||
// Idle: nothing in the log yet — turn-enclosure forbids a bare append.
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
|
||||
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = session?.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
expect(events.filter(e => e.type === 'sandbox/mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'permission/preset')
|
||||
@@ -135,7 +135,7 @@ describe('acp bridge — session config options', () => {
|
||||
expect(back.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
expect(events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
|
||||
})
|
||||
|
||||
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
|
||||
@@ -163,7 +163,7 @@ describe('acp bridge — session config options', () => {
|
||||
const anchored = events.findIndex(e => e.type === 'permission/preset')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
expect(events.some(e => e.type === 'bash/sandbox-mode')).toBe(true)
|
||||
expect(events.some(e => e.type === 'sandbox/mode')).toBe(true)
|
||||
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
|
||||
await h.client.cancel({ sessionId })
|
||||
await hung
|
||||
@@ -213,7 +213,7 @@ describe('acp bridge — session config options', () => {
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
agent.session.append('sandbox/mode', { mode: 'read-only' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The echo of the derived current is a no-op, not an unknown-value error…
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
@@ -34,6 +35,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* User-facing PERMISSION PRESETS: one product-level knob over the two
|
||||
* mechanism knobs. A preset names a bundle — its sandbox mode
|
||||
* (`bash/sandbox-mode`) and its approval policy (`approval/policy`) — so a
|
||||
* (`sandbox/mode`) and its approval policy (`approval/policy`) — so a
|
||||
* user picks `workspace-write` or `danger-full-access` while the mechanism
|
||||
* tiers stay orthogonal capabilities. Switching a preset WRITES THROUGH: one `permission/preset` event
|
||||
* records the chosen bundle (the audit fact reverse-mapping cannot recover —
|
||||
@@ -19,7 +19,10 @@ import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
// Side-effect type import: declaration-merges `ctx.bash` (the capability fact
|
||||
// `sandboxMode` this service reads), without a value dependency on the seam.
|
||||
import type {} from '@deepseek-ai/dsh-bash'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
@@ -33,7 +36,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's permission preset was switched — log-only (the
|
||||
* `bash/sandbox-mode` precedent): durable and replayable, never in the
|
||||
* `sandbox/mode` precedent): durable and replayable, never in the
|
||||
* model transcript. The LAST such event is the session's preset
|
||||
* ({@link effectivePermissionPreset}); the knob events the switch wrote
|
||||
* through follow it in the same turn, and they — not this record of the
|
||||
@@ -48,7 +51,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
* runs under while the preset is active — plus its presentation.
|
||||
*/
|
||||
export interface PresetSpec {
|
||||
/** The `bash/sandbox-mode` value the preset writes through. */
|
||||
/** The `sandbox/mode` value the preset writes through. */
|
||||
sandbox: SandboxMode
|
||||
/** The `approval/policy` value the preset writes through. */
|
||||
approval: ApprovalPolicy
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('PermissionService', () => {
|
||||
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
|
||||
const ctx = await mounted()
|
||||
const session = freshSession('sess-custom')
|
||||
session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
session.append('sandbox/mode', { mode: 'read-only' })
|
||||
expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET)
|
||||
// Switching FROM custom is an ordinary write-through; custom itself is
|
||||
// never a target.
|
||||
@@ -80,7 +80,7 @@ describe('PermissionService', () => {
|
||||
expect(ctx.permission.current(session.events)).toBe('agentish')
|
||||
// A knob drifts: the fold's bundle no longer matches → reverse map wins.
|
||||
session.append('approval/policy', { policy: 'never' })
|
||||
session.append('bash/sandbox-mode', { mode: 'danger-full-access' })
|
||||
session.append('sandbox/mode', { mode: 'danger-full-access' })
|
||||
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('PermissionService', () => {
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
expect(session.events.map(e => [e.type, e.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['bash/sandbox-mode', { mode: 'danger-full-access' }],
|
||||
['sandbox/mode', { mode: 'danger-full-access' }],
|
||||
['approval/policy', { policy: 'never' }],
|
||||
])
|
||||
})
|
||||
@@ -109,12 +109,12 @@ describe('PermissionService', () => {
|
||||
// A knob drifts out from under the preset (a direct setter call, a test
|
||||
// scenario): the session derives custom, and re-asserting the preset is
|
||||
// a real switch again — choice re-recorded, only the drifted knob moves.
|
||||
session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
session.append('sandbox/mode', { mode: 'read-only' })
|
||||
ctx.permission.set(session, 'danger-full-access')
|
||||
const tail = session.events.slice(4)
|
||||
expect(tail.map(e => [e.type, e.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['bash/sandbox-mode', { mode: 'danger-full-access' }],
|
||||
['sandbox/mode', { mode: 'danger-full-access' }],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user