Merge origin/master into worktree/contain-result-observer-rejections

This commit is contained in:
Tianyi Cui
2026-07-20 17:15:41 +08:00
165 changed files with 6165 additions and 1418 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-bash-sandbox
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) and a [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (which owns the default mode + workspace root, shared with the sandboxed filesystem) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
@@ -16,7 +16,7 @@ Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **Deployment default, per-call policy.** The DEFAULT mode + workspace root are owned by [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) (one home both enforcing families read), not this executor's config; `resolve()` stamps the default onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox Agent Note § Escalation](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
@@ -25,11 +25,13 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego
```yaml
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: read-only
workspaceRoot: !!js process.cwd()
- id: bash
name: '@deepseek-ai/dsh-bash-sandbox'
```
The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo.

View File

@@ -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.7"
},
"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.7"
}

View File

@@ -7,52 +7,39 @@
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { resolve } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {} 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'
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
/**
* 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
/**
* Registers as `ctx.bash` in place of the local executor and requires a
* `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is
* the fallback, while a session override or approved one-shot escalation may
* select each call's mode. The prompt does not state the standing mode;
* `result.sandbox` reports the mode and enforcement actually used.
* `ctx.sandbox` provider plus `ctx.sandboxPolicy`; the tool layer is
* unchanged. The policy default (mode + workspace root) is the fallback,
* while a session override or approved one-shot escalation may select each
* call's mode. The prompt does not state the standing mode; `result.sandbox`
* reports the mode and enforcement actually used.
*/
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
@@ -71,9 +58,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
constructor(ctx: Context, config: Config) {
super(ctx, config)
// Schemastery fills mode before construction; workspaceRoot and cwd retain runtime fallbacks.
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. */

View File

@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
@@ -40,7 +41,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
}

View File

@@ -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
}

View File

@@ -12,7 +12,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 { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
@@ -39,7 +40,11 @@ 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.
*/
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 {
@@ -49,7 +54,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 }
@@ -84,14 +93,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'))

View File

@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
@@ -39,7 +40,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
}

View File

@@ -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"
},

View File

@@ -29,7 +29,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).

View File

@@ -23,12 +23,10 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -10,7 +10,6 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts'
export { DSH_ENV_PREFIX } from './types.ts'
export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
export type {
BashExecRequest,
BashExecSpec,

View File

@@ -1,52 +0,0 @@
/**
* Per-session sandbox-mode override stored as log-only events. Folding the log
* isolates sessions and survives replay; the tool stamps the override onto
* each call unless an approved one-shot escalation outranks it, and the
* executor default applies when neither exists. The model receives neither the
* event nor a standing-mode notice; denial results name the effective mode.
* @module dsh-bash/session-mode
*/
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* Durable log-only sandbox-mode override; never a surface event or model
* message. Execution and ACP option reporting fold the latest event through
* {@link effectiveSandboxMode} without adding a prompt notice.
*/
'bash/sandbox-mode': { mode: SandboxMode }
}
}
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
/**
* The session's sandbox-mode override: the last `bash/sandbox-mode` event in
* the log, or undefined when the session never switched and callers should use
* the executor default. Replay needs no separate catch-up 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
}
return undefined
}
/**
* Append one `bash/sandbox-mode` event as the only override write path.
* Execution and ACP option reporting fold it on read; prompt assembly does not
* consume it.
* @param session - the session the override belongs to.
* @param mode - the mode every subsequent bash call in this session runs
* under (until the next switch).
*/
export function setSandboxMode(session: Session, mode: SandboxMode): void {
session.append('bash/sandbox-mode', { mode })
}

View File

@@ -16,9 +16,6 @@
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -23,15 +23,16 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@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-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^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-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
@@ -54,6 +56,7 @@
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -15,12 +15,13 @@ 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 type {} from '@deepseek-ai/dsh-session-persistence'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tasks'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox'
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
import { processOutcome } from './background.ts'
@@ -226,24 +227,11 @@ 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)
}
const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
'read-only': ['workspace-write', 'danger-full-access'],
'workspace-write': ['danger-full-access'],
}
const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
function bashDescription(backgroundEnabled: boolean, escalationModes: readonly SandboxMode[]): string {
const background = backgroundEnabled
? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.'
@@ -346,35 +334,32 @@ export function apply(ctx: Context, config: Config = {}): void {
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
* 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 approval ingredients
* — the seam is consumed opportunistically (`ctx.get`) so a deployment
* without it degrades per call.
*/
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)')
}
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,
reason: `escalate sandbox to ${mode}: ${justification}`,
...exec.signal ? { signal: exec.signal } : {},
})
switch (outcome) {
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 } : {},
},
)
}
// Cross-call guidance belongs in the prompt rather than one-call schema prose.
@@ -417,7 +402,7 @@ export function apply(ctx: Context, config: Config = {}): void {
validateBashArgs(args)
// Description is display metadata; workdir defaults to the caller's session.
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)
const workdir = resolveWorkdir(args.workdir, exec)
const dshEnv = bashEnv.collect(exec)

View File

@@ -6,6 +6,7 @@
import type { BashProcessRead, BashRunResult, BashSandboxInfo, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { escalationHintMarker, sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox'
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
@@ -42,10 +43,10 @@ export function renderResult(
const markers: string[] = []
// Keep the exit marker last because parseExitStatus anchors there.
if (result.sandbox?.denied) {
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
markers.push(sandboxDenialMarker(result.sandbox.mode))
// Hint only when the composition exposes escalation, before the final exit marker.
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'))
}
}
// A command may trap SIGTERM and exit 0 after timeout; still report interruption.
@@ -84,9 +85,9 @@ export function renderProcessRead(
if (sandbox?.runnerFailed) {
notices.push(`[sandbox: the sandbox runner itself failed under ${sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]`)
} else if (sandbox?.denied) {
notices.push(`[sandbox: file access denied under ${sandbox.mode} mode]`)
notices.push(sandboxDenialMarker(sandbox.mode))
if (escalationModes.length > 0) {
notices.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]')
notices.push(escalationHintMarker('command'))
}
}
if (notices.length === 0) return read.delta

View File

@@ -181,7 +181,7 @@ async function setupSandboxed(withApproval = false) {
function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent {
const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
if (mode !== undefined) events.push({ type: 'bash/sandbox-mode', data: { mode } })
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
const id = SessionId('sandbox-session')
return {
id,
@@ -553,7 +553,7 @@ describe('sandbox escalation through the generic task producer', () => {
const malformed = sandboxAgent()
;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
type: 'bash/sandbox-mode',
type: 'sandbox/mode',
data: { mode: 'unknown-mode' },
})
expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider')
@@ -610,7 +610,7 @@ describe('sandbox escalation through the generic task producer', () => {
const { ctx } = await setupSandboxed(true)
ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
const result = await call(ctx, 'bash', escalate, sandboxAgent())
expect(text(result)).toContain('unreachable variant in ApprovalOutcome')
expect(text(result)).toContain('unreachable variant in EscalationOutcome')
})
})

View File

@@ -46,6 +46,9 @@
},
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
}
]
}

View File

@@ -42,7 +42,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
The core `context/message` envelope is disabled for these messages because the plugin already owns the complete `<system-reminder>` framing. This is caller-selected with `envelope: 'raw'`; ordinary injected context still receives the canonical `<context source="...">` envelope.
The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
## State And Refresh

View File

@@ -92,7 +92,6 @@ export function apply(ctx: Context, config: Config): void {
if (update !== undefined) {
agent.inject(update.context.content, {
source: update.context.source,
envelope: update.context.envelope,
meta: update.context.meta,
})
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)

View File

@@ -163,6 +163,12 @@ function buildInstructionText(
): string {
const marker = markerText(maxBytes, omitted, truncated)
const body = [marker, style.intro, ...files.map(file => style.section(file))].filter(block => block.length > 0)
// Caller-owned framing: the plugin bakes the complete `<system-reminder>`
// frame into the message content. The session surface projects context
// verbatim and does not wrap it, so any framing must live here in the
// producer's content (the pattern a future `meta`-driven renderer would
// generalize — see the deferred note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md).
return [SYSTEM_REMINDER_OPEN, body.join('\n\n'), SYSTEM_REMINDER_CLOSE].join('\n')
}

View File

@@ -61,9 +61,8 @@ export interface ReconciledInstructionContext {
versionUpdates: InstructionVersionUpdate[]
}
/** Plugin-owned raw context with required replay metadata. */
/** Plugin-owned context with required replay metadata. */
export interface WorkspaceHookContext extends HookContext {
envelope: 'raw'
meta: JsonValue
}
@@ -76,7 +75,7 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
...change.digest !== undefined ? { digest: change.digest } : {},
}))
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, envelope: 'raw', meta }
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE, meta }
}
/**

View File

@@ -173,7 +173,6 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
session.append('context/message', {
content,
source: options?.source ?? { kind: 'user' },
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
...options?.meta !== undefined ? { meta: options.meta } : {},
}, { surfaceOp: 'append' })
},
@@ -202,7 +201,6 @@ function workspaceChangeContext(scope: string, digest: string): HookContext {
return {
content: [{ type: 'text', text: `instructions for ${scope}` }],
source: { kind: 'plugin', plugin: 'workspace-context' },
envelope: 'raw',
meta: {
kind: 'workspace-instructions',
version: 1,
@@ -217,7 +215,6 @@ function appendAdditionalContexts(agent: Agent, result: { additionalContexts?: H
lastSeq = agent.session.append('context/message', {
content: context.content,
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' }).seq
}
@@ -1695,7 +1692,6 @@ describe('dynamic nested workspace context injection', () => {
expect(result.isError).toBe(false)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.envelope).toBe('raw')
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
version: 1,
@@ -2461,7 +2457,6 @@ describe('dynamic nested workspace context injection', () => {
expect(blocksText(result.content)).toBe('downstream replacement')
expect(result.additionalContexts).toHaveLength(2)
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.envelope).toBe('raw')
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
@@ -2474,7 +2469,8 @@ describe('dynamic nested workspace context injection', () => {
})
const agent = stubAgent(root)
appendAdditionalContexts(agent, result)
expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('<context source="plugin">\ndownstream context\n</context>')
expect(blocksText(agent.session.deriveMessages()[1]?.content)).toContain('downstream context')
expect(blocksText(agent.session.deriveMessages()[1]?.content)).not.toContain('<context source=')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2807,7 +2803,6 @@ describe('workspace context pending state', () => {
const otherWorkspaceEvent = agent.session.append('context/message', {
content: otherContext.content,
source: otherContext.source,
...otherContext.envelope !== undefined ? { envelope: otherContext.envelope } : {},
...otherContext.meta !== undefined ? { meta: otherContext.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, otherWorkspaceEvent, pending, versions)
@@ -2817,7 +2812,6 @@ describe('workspace context pending state', () => {
const confirmed = agent.session.append('context/message', {
content: context.content,
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
}, { surfaceOp: 'append' })
observeInstructionSessionEvent(agent.session, confirmed, pending, versions)

View File

@@ -241,12 +241,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */',
},
{
signature: 'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>',
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @returns the outcome, including the version the write produced.\n */',
signature: 'abstract writeText( target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsWriteOutcome>',
jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this write runs under; a\n * sandboxing backend fences the write by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the write produced.\n */',
},
{
signature: 'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>',
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @returns the outcome, including the version the edit produced.\n */',
signature: 'abstract editText( target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal, sandboxMode?: SandboxMode, ): Promise<FsEditOutcome>',
jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @param sandboxMode - the per-call sandbox mode this edit runs under; a\n * sandboxing backend fences the edit by it, the bare backend ignores it.\n * Omit to leave the backend its own default.\n * @returns the outcome, including the version the edit produced.\n */',
},
],
},
@@ -304,6 +304,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'sandboxPolicy',
summary: 'The sandbox-policy service (`ctx.sandboxPolicy`).',
methods: [],
},
{
key: 'sessionPersistence',
summary: 'Durable append-only session storage.',
@@ -1068,10 +1073,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ContentBlockType',
declaration: 'export type ContentBlockType = keyof ContentBlockMap;',
},
{
name: 'ContextEnvelope',
declaration: 'export type ContextEnvelope = \'context\' | \'raw\';',
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
@@ -1170,11 +1171,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n}',
},
{
name: 'InjectOptions',
declaration: 'export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n}',
declaration: 'export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n}',
},
{
name: 'JsonValue',
@@ -1258,7 +1259,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource; /* …truncated — full shape in source */',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */',
},
{
name: 'SessionEventReadRequest',

View File

@@ -252,7 +252,6 @@ export class ReactLoopAgent implements Agent {
const context = {
content,
source,
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
...options?.meta !== undefined ? { meta: options.meta } : {},
}
if (isTurnOpen(this.session)) {

View File

@@ -260,11 +260,10 @@ async function runTurn(
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
// Every `allow.additionalContexts` entry is a separate context/message the
// next request also sees. The turn is open, so inject() appends each one
// into THIS turn without flattening provenance, framing, or metadata.
// into THIS turn without flattening provenance or metadata.
for (const context of decision.additionalContexts ?? []) {
agent.inject(context.content, {
source: context.source,
...context.envelope !== undefined ? { envelope: context.envelope } : {},
...context.meta !== undefined ? { meta: context.meta } : {},
})
}

View File

@@ -99,7 +99,6 @@ describe('agent/prompt-submit', () => {
additionalContexts: [{
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
source: { kind: 'plugin', plugin: 'test' },
envelope: 'raw',
meta,
}],
}))
@@ -113,7 +112,6 @@ describe('agent/prompt-submit', () => {
expect(userMsg).toBeDefined()
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw')
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
const sent = JSON.stringify(adapter.requests[0]!.messages)
expect(sent).toContain('extra ctx')
@@ -556,7 +554,6 @@ describe('tool additionalContexts buffering across a step', () => {
additionalContexts: [{
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
source: { kind: 'plugin', plugin: 'p' },
envelope: 'raw',
meta: { callId: exec.callId },
}],
}))
@@ -580,7 +577,6 @@ describe('tool additionalContexts buffering across a step', () => {
.map(b => (b.type === 'text' ? b.text : ''))
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
const contextEvents = events(agent).filter(e => e.type === 'context/message')
expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw'])
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
})
@@ -591,7 +587,7 @@ describe('tool additionalContexts buffering across a step', () => {
name: 'composite', description: 'composite', parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, meta: { order: 2 } })
return [{ type: 'text', text: 'outer result' }]
},
}))

View File

@@ -385,10 +385,10 @@ describe('agent loop', () => {
await waitForIdle(ctx, agent)
const flat = JSON.stringify(adapter.requests[0]!.messages)
expect(flat).toContain('file changed: a.ts')
expect(flat).toContain('<context source=\\"plugin\\">')
expect(flat).not.toContain('<context source=')
})
it('inject() can persist raw structured context without the generic context envelope', async () => {
it('inject() persists structured context content verbatim with durable hidden meta', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
@@ -401,14 +401,13 @@ describe('agent loop', () => {
agent.inject([{ type: 'text', text }], {
source: { kind: 'plugin', plugin: 'workspace-context' },
envelope: 'raw',
meta,
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ envelope: 'raw', meta })
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
const requestText = JSON.stringify(adapter.requests[0]!.messages)
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
expect(requestText).not.toContain('<context source=')
@@ -432,7 +431,6 @@ describe('agent loop', () => {
const first = { type: 'text' as const, text: 'mid-turn notice' }
agent.inject([first], {
source: { kind: 'plugin', plugin: 'x' },
envelope: 'raw',
meta,
})
first.text = 'mutated after inject'
@@ -458,7 +456,6 @@ describe('agent loop', () => {
expect(contexts).toHaveLength(2)
expect(result.seq).toBeLessThan(contexts[0]!.seq)
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
envelope: 'raw',
meta,
})
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))

View File

@@ -46,7 +46,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
@@ -56,7 +56,7 @@ The handle every plugin programs against:
- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message`. `options.envelope` defaults to the canonical `<context>` framing and may be `'raw'` when the caller owns a complete familiar frame; `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`

View File

@@ -8,7 +8,7 @@
import type { Context } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { ContextEnvelope, JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
@@ -32,8 +32,6 @@ export interface SendOptions {
/** Options specific to durable synthetic context injection. */
export interface InjectOptions extends SendOptions {
/** Keep the canonical context tag, or send caller-owned framing verbatim. */
envelope?: ContextEnvelope
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue
}
@@ -50,8 +48,6 @@ export type AgentStatus = 'idle' | 'running' | 'disposed'
export interface HookContext {
content: ContentBlock[]
source: MessageSource
/** Keep the canonical context tag, or use caller-owned framing verbatim. */
envelope?: ContextEnvelope
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue
}

View File

@@ -56,7 +56,7 @@ Durable values need one accepted representation, not a check followed by a secon
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`context/message` defaults to the canonical tagged context projection. A producer may set `envelope: 'raw'` when its `content` already contains the complete model-facing frame, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
### Session event vocabulary (`types.ts`)
@@ -87,7 +87,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
#### What the model sees
The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly `<context source="<source-kind>">`, its content blocks, and `</context>`; `steering/message` uses the identical `<steering source="<source-kind>">` / `</steering>` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect

View File

@@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -80,21 +80,6 @@ declare module 'cordis' {
}
}
/**
* Render injected context as tagged synthetic user-role content, keeping the
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
* belong in the adapter.
*/
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
const close = `</${tag}>`
return [
{ type: 'text', text: open },
...content,
{ type: 'text', text: close },
]
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
@@ -228,22 +213,6 @@ interface SessionEntry {
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
const attachments = new WeakMap<Session, SessionEntry>()
/**
* Render one context contribution exactly as it will appear in model history.
* @param content - content blocks supplied by the context producer.
* @param source - attribution used by the canonical context envelope.
* @param envelope - canonical tagged framing or caller-owned raw framing.
* @returns a detached block list ready for the derived model transcript.
*/
export function renderContextContent(
content: ContentBlock[],
source: MessageSource,
envelope: ContextEnvelope = 'context',
): ContentBlock[] {
const cloned = structuredClone(content)
return envelope === 'raw' ? cloned : renderTagged('context', cloned, source)
}
/**
* An event-sourced session: an append-only log of {@link SessionEvent}s.
*
@@ -509,7 +478,18 @@ export class Session {
// trace/replay data.
switch (event.type) {
case 'user/message': {
// Injected context and mid-turn steering project identically to a user
// prompt: content verbatim, in user role. context's `source`/`meta` and
// steering's `turn` are log-only and do not reach the model. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by
// the event `meta` map and a dedicated renderer, keeping this projection a
// verbatim pass-through. See the deferred design note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
case 'user/message':
case 'context/message':
case 'steering/message': {
return { role: 'user', content: event.data.content }
}
case 'assistant/message': {
@@ -526,14 +506,6 @@ export class Session {
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
}
}
case 'context/message': {
const { content, source, envelope } = event.data
return { role: 'user', content: renderContextContent(content, source, envelope) }
}
case 'steering/message': {
const { content, source } = event.data
return { role: 'user', content: renderTagged('steering', content, source) }
}
default:
// A non-surface event (boundary, chunk, log-only record) projects to
// no message. Merge-extensible union: no assertNever here.

View File

@@ -2,9 +2,6 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from './json.ts'
/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */
export type ContextEnvelope = 'context' | 'raw'
/** Identifies one session in the store (and its persistence artifacts). */
export type SessionId = Branded<'SessionId'>
@@ -205,14 +202,17 @@ export interface SessionEventMap {
/**
* In-session context injection (file-change notices, subdir AGENTS.md,
* skill content, cron notifications, …). Rendered into the derived history
* as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller
* own the complete model-facing frame; `meta` is durable JSON state omitted
* from the model projection.
* as a synthetic user-role message carrying `content` verbatim — NOT a
* user prompt. `meta` is durable JSON state omitted from the model
* projection; it is also the intended channel for any future framing
* directive (a producer declares the frame, a dedicated renderer applies it —
* see the deferred note in
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
* so the surface keeps projecting `content` verbatim rather than wrapping it.
*/
'context/message': {
content: ContentBlock[]
source: MessageSource
envelope?: ContextEnvelope
meta?: JsonValue
}
/** Raw stream chunk — token-level replay fidelity. */

View File

@@ -48,7 +48,7 @@ describe('Session', () => {
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
it('renders context and steering messages as tagged synthetic user content', () => {
it('renders context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
session.append('context/message', {
content: [{ type: 'text', text: 'file changed: a.ts' }],
@@ -62,12 +62,12 @@ describe('Session', () => {
const [contextMessage, steeringMessage] = session.deriveMessages()
expect(contextMessage!.role).toBe('user')
expect(contextMessage!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
expect(contextMessage!.content.at(-1)).toMatchObject({ type: 'text', text: '</context>' })
expect(steeringMessage!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
expect(contextMessage!.content).toEqual([{ type: 'text', text: 'file changed: a.ts' }])
expect(steeringMessage!.role).toBe('user')
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
})
it('renders raw context without a generic envelope while preserving structured metadata', () => {
it('keeps context meta durable in the event while hiding it from the projection', () => {
const session = new Session(SessionId('s2-raw'))
const meta = {
kind: 'workspace-instructions',
@@ -77,7 +77,6 @@ describe('Session', () => {
session.append('context/message', {
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
envelope: 'raw',
meta,
}, { surfaceOp: 'append' })

View File

@@ -369,8 +369,8 @@ describe('deriveMessages with surface', () => {
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
expect(messages[1]!.content).toEqual([{ type: 'text', text: 'focus' }])
})
})

View File

@@ -38,7 +38,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws; it never injects immediately.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source, envelope, and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContexts?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text. `additionalContexts` preserves each deferred or post-execute `HookContext` with its own source and durable JSON metadata; the loop buffers the array and appends each entry as a `context/message` after all `tool/result`s in the step.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision``{kind:'accept', content?, additionalContexts?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContexts?}` (turn it into an `isError` whose content is the corrective feedback). Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision.
- `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
@@ -108,7 +108,7 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails.
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
### Parallel execution

View File

@@ -235,8 +235,8 @@ export interface ToolExecution extends ToolExecutionInput {
export interface ToolRunContext extends ToolExecution {
/**
* Defer one nested-dispatch context until this tool's final result reaches
* the agent loop. Contexts retain their individual source, envelope, and
* metadata and are emitted in call order.
* the agent loop. Contexts retain their individual source and metadata and
* are emitted in call order.
*/
deferContext(context: HookContext): void
}

View File

@@ -487,7 +487,6 @@ describe('the run_code dispatch bridge', () => {
additionalContexts: [{
content: [{ type: 'text' as const, text: `context for ${exec.callId}` }],
source: { kind: 'plugin' as const, plugin: 'test' },
envelope: 'raw' as const,
meta: { callId: exec.callId },
}],
})
@@ -505,13 +504,11 @@ describe('the run_code dispatch bridge', () => {
{
content: [{ type: 'text', text: 'context for call-1:code:1' }],
source: { kind: 'plugin', plugin: 'test' },
envelope: 'raw',
meta: { callId: 'call-1:code:1' },
},
{
content: [{ type: 'text', text: 'context for call-1:code:2' }],
source: { kind: 'plugin', plugin: 'test' },
envelope: 'raw',
meta: { callId: 'call-1:code:2' },
},
])

View File

@@ -384,7 +384,7 @@ describe('ToolRegistry', () => {
parameters: {},
async execute(_args, exec) {
exec.deferContext({ content: [{ type: 'text', text: 'nested-1' }], source: { kind: 'plugin', plugin: 'nested-1' }, meta: { n: 1 } })
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' }, envelope: 'raw' })
exec.deferContext({ content: [{ type: 'text', text: 'nested-2' }], source: { kind: 'plugin', plugin: 'nested-2' } })
return [{ type: 'text', text: 'done' }]
},
}))
@@ -418,7 +418,6 @@ describe('ToolRegistry', () => {
{ kind: 'plugin', plugin: 'post' },
])
expect(result.additionalContexts?.[0]?.meta).toEqual({ n: 1 })
expect(result.additionalContexts?.[1]?.envelope).toBe('raw')
})
it('keeps deferred contexts when a composite tool throws, but drops them when the outer call is blocked', async () => {

View File

@@ -32,12 +32,13 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `workspaceContext` | (required) | workspace-instruction byte budget/config, or `false`; routed to the providerless-safe `dsh-workspace-context` plugin |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor.
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay), a bash executor, and optionally a `ctx.fs` provider. Workspace context becomes a no-op without `ctx.fs`; the shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) selects `dsh-sandbox-policy`, `dsh-fs-sandbox`, `dsh-fs-policy`, and `dsh-tool-fs` so baseline instructions and model-facing `read`/`write`/`edit` share one provider, sandbox mode, workspace root, and observed-version policy.
## The bin

View File

@@ -6,11 +6,12 @@ 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`) |
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (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. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
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 Agent Note](../../.agents/notes/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. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
## No timeouts on file IO

View File

@@ -0,0 +1,33 @@
# 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](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
## Model Experience
Indirectly, through `dsh-tool-fs`, which renders this backend's `FS_SANDBOX_DENIED` refusals as the `[sandbox: file access denied under <mode> mode]` marker plus the same-turn escalation hint.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **A policy fence, not a kernel boundary** — the check is trusted code over a model-controlled path, so the residual resolve-to-syscall TOCTOU is narrowed (by the in-place re-canonicalization) but not eliminated; adversarial host processes are out of scope. Kernel-grade isolation of untrusted code stays `ctx.bash`'s.
- **Fence-vs-runner parity is derived, not asserted** — the writable set comes from `writableRoots`, shared with the Seatbelt profile and pinned by a parity test; a runner profile that changed its writable set without that function would drift.
- **Requires `ctx.sandboxPolicy`** — the backend reads the default mode and workspace root from it and does not confine without it composed.

View 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.7"
},
"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.7"
}
}

View File

@@ -0,0 +1,157 @@
/**
* `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 checkedTarget}.
* @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> {
return super.writeText(await this.checkedTarget(target, sandboxMode), content, expected, signal)
}
/**
* Fence the edit by the per-call mode, then delegate to the inherited
* atomic edit. See {@link checkedTarget}.
* @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> {
return super.editText(await this.checkedTarget(target, sandboxMode), edit, expected, signal)
}
/**
* Enforce the per-call mode against `target` and return the EXACT target the
* mutation must use, so the checked identity is the mutated one (no
* check-here-write-there TOCTOU). `read-only` denies; `workspace-write`
* re-canonicalizes NOW (`resolve` realpaths the deepest existing ancestor,
* reflecting a concurrently swapped symlink), requires containment under a
* writable root, and returns THAT fresh target; `danger-full-access` returns
* the caller's target unfenced. 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 checkedTarget(target: FsTarget, sandboxMode?: SandboxMode): Promise<FsTarget> {
const mode = sandboxMode ?? this.defaultMode
if (mode === 'danger-full-access') return target
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), and the
// mutation delegates with THIS fresh target — never the stale one.
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')
}
return fresh
}
}
export default SandboxedFileSystem

View File

@@ -0,0 +1,237 @@
/**
* 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, FsTargetKey } 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('mutates the freshly checked identity, not a stale outside targetKey (TOCTOU direction)', async () => {
// A target whose displayPath is inside the workspace but whose targetKey is
// a STALE outside path — as if an ancestor symlink pointed out at the tool's
// resolve() and was swapped in before the write. The fence re-resolves
// displayPath (now inside) AND delegates with that fresh target, so the byte
// lands inside and the stale outside path is never written.
const insidePath = join(workspace, 'landed.txt')
const staleTarget: FsTarget = { displayPath: insidePath, targetKey: FsTargetKey(join(outside, 'escaped.txt')) }
await fs.writeText(staleTarget, 'inside')
expect(await readFile(insidePath, 'utf8')).toBe('inside')
expect(existsSync(join(outside, 'escaped.txt'))).toBe(false)
})
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')
})
})

View 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"
}
]
}

View File

@@ -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.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -7,6 +7,7 @@
*/
import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {
FsDirEntry,
FsEditOutcome,
@@ -82,6 +83,23 @@ 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 to a stable identity), hence
@@ -152,9 +170,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>
/**
* Atomically edit literal text. When supplied, the version guard is checked
@@ -164,9 +191,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

View File

@@ -168,6 +168,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'

View File

@@ -9,6 +9,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../util/brand" },
{ "path": "../../llm/llm" }
{ "path": "../../llm/llm" },
{ "path": "../../sandbox/sandbox" }
]
}

View File

@@ -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.7"
},
"devDependencies": {
@@ -42,9 +45,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.7"
}
}

View File

@@ -13,6 +13,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 { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
/** Validated `edit` arguments after defaulting. */
interface EditInput {
@@ -22,6 +23,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`
@@ -56,8 +71,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,
@@ -72,20 +88,31 @@ 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 target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// 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)
// An edit necessarily changes content, so result metadata carries at least one applied hunk.

View File

@@ -7,10 +7,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'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs'
@@ -61,6 +63,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)
}

View File

@@ -0,0 +1,135 @@
/**
* 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 a
* `FsError` whose text is the shared `[sandbox: …]` denial marker plus the
* same-turn escalation hint, so a policy denial reads identically to bash's
* WHILE keeping the structured `FS_SANDBOX_DENIED` code — `ToolRegistry`
* populates `result.error` only for `HarnessError` instances, so a plain
* `Error` would strip the code retry/observers key off. 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 `FsError` 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 FsError(`${sandboxDenialMarker(mode)}\n${escalationHintMarker('operation')}`, 'FS_SANDBOX_DENIED', { cause: error })
}
}

View File

@@ -14,6 +14,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 { sessionResolveOptions } from './session-cwd.ts'
import type { FsSandboxSurface } from './sandbox.ts'
/**
* Validate value constraints the schema DSL can't express: only a non-blank
@@ -41,11 +42,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,
@@ -58,14 +72,26 @@ 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 target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec))
// 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)
// Overwrites carry applied hunks. Creates have no prior text, so result presentation uses

View File

@@ -24,6 +24,8 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { STREAM_MIN_SIZE } from '../src/read.ts'
import { formatReadOutput } from '../src/read-render.ts'
import type { FileReadOutcome } from '../src/read-render.ts'
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 {
@@ -580,3 +582,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')
})
})

View File

@@ -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" }
]
}

View File

@@ -30,7 +30,7 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de
## Reminder delivery
Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source, envelope, and metadata.
Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata.
## Testing

View File

@@ -140,7 +140,7 @@ function validateThresholds(values: number[]): number[] {
/**
* Prepend the guard's reminder while preserving every downstream context's
* source, envelope, and metadata.
* source and metadata.
*/
function prependContext(ours: HookContext, theirs: HookContext[] | undefined): HookContext[] {
return [ours, ...theirs ?? []]

View File

@@ -508,7 +508,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
additionalContexts: [{
content: [{ type: 'text' as const, text: 'from-downstream' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
@@ -527,7 +526,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
{ kind: 'plugin', plugin: 'hooks-claude' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
@@ -561,7 +559,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
@@ -574,7 +571,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
{ kind: 'plugin', plugin: 'hooks-claude' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})

View File

@@ -125,7 +125,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
additionalContexts: [{
content: [{ type: 'text' as const, text: 'from-downstream' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
@@ -140,7 +139,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})
})
@@ -171,7 +169,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
additionalContexts: [{
content: [{ type: 'text' as const, text: 'downstream-note' }],
source: { kind: 'plugin' as const, plugin: 'policy' },
envelope: 'raw' as const,
meta: { owner: 'policy' },
}],
}))
@@ -183,7 +180,6 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
{ kind: 'plugin', plugin: 'hooks-codex' },
{ kind: 'plugin', plugin: 'policy' },
])
expect(contexts[1]?.type === 'context/message' && contexts[1].data.envelope).toBe('raw')
expect(contexts[1]?.type === 'context/message' && contexts[1].data.meta).toEqual({ owner: 'policy' })
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
/**
* The escalation vocabulary and choreography shared by every sandbox-enforcing
* tool family (`@deepseek-ai/dsh-tool-bash`, `@deepseek-ai/dsh-tool-fs`): the
* strictly-wider ladder, the argument-pairing validation, the model-facing
* denial/hint markers, and {@link approveEscalation} — the ordered fail-closed
* sequence that resolves a `sandbox_permissions` request through a
* user-approval channel BEFORE anything executes. One home keeps the two
* families' approval ordering and verbatim error texts from drifting apart.
*
* The channel is a minimal STRUCTURAL function shape ({@link EscalationAsk}),
* not the approval service type: the tool layer — which owns the agent, the
* call id, and the tool name — closes over `ctx.approval.request(...)` and
* hands the closure down, so this package never depends on the approval or
* agent packages.
*
* @module dsh-sandbox/escalation
*/
import { assertNever } from '@deepseek-ai/dsh-llm'
import type { SandboxMode } from './index.ts'
/**
* The strictly-wider table: what a call whose effective mode is the key may
* escalate TO. Checked at EXECUTION, never baked into a tool schema — the
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
* registry-global while the effective mode is per-call truth.
*/
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
'read-only': ['workspace-write', 'danger-full-access'],
'workspace-write': ['danger-full-access'],
}
/**
* The closed escalation-target vocabulary — every mode a call could ever
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
* whenever the mounted capability confines: cutting the enum down to the modes
* wider than the composition's DEFAULT would strand a session whose effective
* mode sits below it (a `danger-full-access` default would advertise nothing
* while a narrower-switched session stays confined with no lever).
*/
export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
/**
* Validate the escalation argument pairing a tool schema cannot express:
* `sandbox_permissions` and `justification` travel together — an approval
* prompt without a reason, or a reason driving nothing, is a malformed ask —
* and the justification must be a non-empty sentence.
* @param sandboxPermissions - the raw `sandbox_permissions` argument, if given.
* @param justification - the raw `justification` argument, if given.
*/
export function validateEscalationArgs(sandboxPermissions: string | undefined, justification: string | undefined): void {
if (sandboxPermissions !== undefined && justification === undefined) {
throw new Error('invalid escalation: sandbox_permissions requires a justification')
}
if (justification !== undefined && sandboxPermissions === undefined) {
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
}
if (justification !== undefined && justification.trim().length === 0) {
throw new Error('invalid justification: expected a non-empty sentence')
}
}
/**
* The model-facing denial marker — the one vocabulary both enforcing families
* teach and report, so the model recognizes a policy denial identically
* whether the kernel refused a bash file effect or the filesystem provider's
* fence refused a mutation.
* @param mode - the mode the denied call ran under.
* @returns the marker line, exactly as the model sees it.
*/
export function sandboxDenialMarker(mode: SandboxMode): string {
return `[sandbox: file access denied under ${mode} mode]`
}
/**
* The same-turn escalation hint that rides a denial when the composition
* advertises the escalation fields — the nudge lives at the decision point so
* the sanctioned retry does not depend on the model recalling the tool
* description.
* @param subject - the family's noun for the denied action (`command` for
* bash, `operation` for a filesystem mutation).
* @returns the hint line, exactly as the model sees it.
*/
export function escalationHintMarker(subject: string): string {
return `[sandbox: escalation available — retry this exact ${subject} once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`
}
/**
* The closed outcome vocabulary of one escalation ask — structurally identical
* to the approval seam's `ApprovalOutcome` so an `ApprovalService.request`
* return is assignable without this package importing it.
*/
export type EscalationOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
/**
* The minimal approval-request shape {@link approveEscalation} needs —
* structurally the approval seam's `ApprovalService`, generic over the agent
* type `A` and call-id type `C` so this package resolves escalations through
* `ctx.approval` without importing the approval or agent packages (the tool
* layer infers `A`/`C` as its own `Agent`/`CallId`).
*/
export interface EscalationApprover<A = object, C = string> {
/**
* Ask the human to approve one action, resolving to a closed outcome.
* @param req - the audit-self-contained request (agent, tool, call id, reason, optional signal).
* @returns the human's decision as a closed {@link EscalationOutcome}.
*/
request(req: { agent: A; toolName: string; callId: C; reason: string; signal?: AbortSignal }): Promise<EscalationOutcome>
}
/**
* The approval ingredients an escalating tool hands {@link approveEscalation}:
* the approval requester (`ctx.approval`, or `undefined` when none is
* composed), the calling agent (or `undefined` for an agent-less execution),
* and the call's identity. The tool layer holds all of these; this package
* only judges them.
*/
export interface EscalationApproval<A = object, C = string> {
/** The approval requester (`ctx.approval`), or `undefined` when none is composed. */
approver: EscalationApprover<A, C> | undefined
/** The calling agent, or `undefined` for an agent-less execution (fails closed). */
agent: A | undefined
/** The tool-call id the approval prompt attaches to. */
callId: C
/** The tool name recorded on the approval request. */
toolName: string
/** The tool-execution abort signal the approval request rides, when present. */
signal?: AbortSignal
}
/** One escalation request, as {@link approveEscalation} judges it. */
export interface EscalationRequest {
/** The requested target mode (schema-pinned to {@link ESCALATION_TARGETS} when advertised). */
requestedMode: string
/** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */
justification: string
/** The call's effective mode (session override ?? composition default) the request must strictly widen. */
effectiveMode: SandboxMode
/** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */
subject: string
}
/**
* Resolve a sandbox-escalation request BEFORE anything executes: check strict
* widening against the call's effective mode, then resolve the approval
* channel, then map every outcome — the ordered fail-closed sequence both
* enforcing families share. Returns the granted mode to stamp onto exactly
* this call; throws the distinct verbatim text for every other path (a
* non-widening request, a missing approval service, an agent-less execution,
* a rejection, a cancellation, an unanswerable ask) — the tool registry turns
* the throw into the call's isError result, and nothing has run. A
* non-widening request never prompts a human.
* @param request - the escalation to judge (see {@link EscalationRequest}).
* @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}).
* @returns the granted mode, consumed by the one call that asked.
*/
export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {
const { requestedMode: mode, effectiveMode, justification, subject } = request
// Strict widening is an EXECUTION check against the call's effective mode —
// deliberately not a schema constraint (the enum is the closed target
// vocabulary; the effective mode is per-call truth).
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
}
if (approval.approver === undefined) {
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
}
if (approval.agent === undefined) {
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
}
// Self-contained for the audit trail: approval/asked stores this reason,
// and the target mode is part of the grant's identity.
const outcome = await approval.approver.request({
agent: approval.agent,
toolName: approval.toolName,
callId: approval.callId,
reason: `escalate sandbox to ${mode}: ${justification}`,
...approval.signal ? { signal: approval.signal } : {},
})
switch (outcome) {
// The schema enum already pinned `mode` to the closed target vocabulary;
// the check above proved it is strictly wider.
case 'allowed-once': return mode as SandboxMode
case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"`)
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
default: return assertNever(outcome, 'EscalationOutcome')
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -199,12 +199,12 @@ describe('acp bridge — session config options', () => {
expect(after.configOptions).toEqual(optionsWithPermission('danger-full-access'))
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')
@@ -234,7 +234,7 @@ describe('acp bridge — session config options', () => {
expect(back.configOptions).toEqual(optionsWithPermission('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 () => {
@@ -262,7 +262,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
@@ -332,7 +332,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' } })
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
const option = echo.configOptions?.find(entry => entry.id === 'permission')

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-permission
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `bash/sandbox-mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs.
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs.
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.

View File

@@ -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.7"
@@ -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.7"

View File

@@ -12,7 +12,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'
@@ -36,7 +39,7 @@ declare module '@deepseek-ai/dsh-session' {
/** One preset's sandbox/approval bundle and optional client 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

View File

@@ -51,7 +51,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)
ctx.permission.set(session, 'danger-full-access')
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
@@ -74,7 +74,7 @@ describe('PermissionService', () => {
ctx.permission.set(session, 'agentish')
expect(ctx.permission.current(session.events)).toBe('agentish')
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')
})
@@ -84,7 +84,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' }],
])
})
@@ -102,12 +102,12 @@ describe('PermissionService', () => {
ctx.permission.set(session, 'danger-full-access')
// Re-selecting from a drifted state records the choice and repairs only
// the changed knob.
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' }],
])
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../sandbox/sandbox"
},
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../bash/bash"
},