Files
deepseek-harness/packages/sandbox/sandbox/tests/escalation.spec.ts
T
kingwl 2dc62497ce feat(sandbox): cross-family file sandbox — one policy home, sandboxed fs provider, fs escalation parity
Extend SandboxMode enforcement from bash to the filesystem tools, the sandbox
RFC's deferred cross-family phase.

- dsh-sandbox-policy (new, ctx.sandboxPolicy): the single home for the
  deployment default mode + workspaceRoot and the per-session override event,
  renamed bash/sandbox-mode -> sandbox/mode and moved here with its fold/setter.
  Decouples the bash seam from dsh-session.
- dsh-fs-sandbox (new): SandboxedFileSystem extends LocalFileSystem and fences
  write/edit by the per-call mode (read-only denies, workspace-write contains to
  the workspace + temp roots via the shared writableRoots, danger passes
  through); reads pass through. Structured FS_SANDBOX_DENIED; in-lock parent
  re-canonicalization. A policy fence in trusted code, not a kernel boundary.
- dsh-sandbox: the shared escalation kit (writableRoots, the strictly-wider
  ladder, denial/hint markers, approveEscalation) both tool families use;
  approveEscalation takes a structural approver so dsh-sandbox gains no
  approval/agent dependency, and both tools stay duplication-free.
- tool-fs: write/edit advertise sandbox_permissions/justification under a
  confining ctx.fs, map FS_SANDBOX_DENIED to the shared [sandbox: ...] marker,
  and resolve the same one-approved-wider retry.
- examples/acp-agent: composes sandbox-policy + fs-sandbox, drops the gating
  that disabled the fs stack under confined modes.

RFC docs/rfc/implemented/feature/2026-07-14-cross-family-fs-sandbox.md; the old
sandbox RFC's In-process/deferred/FAQ sections updated to shipped fact.
2026-07-14 20:05:57 +08:00

112 lines
5.6 KiB
TypeScript

/**
* 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()
})
})