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.
112 lines
5.6 KiB
TypeScript
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()
|
|
})
|
|
})
|