Merge branch 'codex/invariant-service-seam' into codex/invariant-package-registration-gate
This commit is contained in:
189
packages/sandbox/sandbox/src/escalation.ts
Normal file
189
packages/sandbox/sandbox/src/escalation.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* The escalation vocabulary and choreography shared by every sandbox-enforcing
|
||||
* tool family (`@deepseek-ai/dsh-tool-bash`, `@deepseek-ai/dsh-tool-fs`): the
|
||||
* strictly-wider ladder, the argument-pairing validation, the model-facing
|
||||
* denial/hint markers, and {@link approveEscalation} — the ordered fail-closed
|
||||
* sequence that resolves a `sandbox_permissions` request through a
|
||||
* user-approval channel BEFORE anything executes. One home keeps the two
|
||||
* families' approval ordering and verbatim error texts from drifting apart.
|
||||
*
|
||||
* The channel is a minimal STRUCTURAL function shape ({@link EscalationAsk}),
|
||||
* not the approval service type: the tool layer — which owns the agent, the
|
||||
* call id, and the tool name — closes over `ctx.approval.request(...)` and
|
||||
* hands the closure down, so this package never depends on the approval or
|
||||
* agent packages.
|
||||
*
|
||||
* @module dsh-sandbox/escalation
|
||||
*/
|
||||
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { SandboxMode } from './index.ts'
|
||||
|
||||
/**
|
||||
* The strictly-wider table: what a call whose effective mode is the key may
|
||||
* escalate TO. Checked at EXECUTION, never baked into a tool schema — the
|
||||
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are
|
||||
* registry-global while the effective mode is per-call truth.
|
||||
*/
|
||||
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
|
||||
'read-only': ['workspace-write', 'danger-full-access'],
|
||||
'workspace-write': ['danger-full-access'],
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed escalation-target vocabulary — every mode a call could ever
|
||||
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
|
||||
* whenever the mounted capability confines: cutting the enum down to the modes
|
||||
* wider than the composition's DEFAULT would strand a session whose effective
|
||||
* mode sits below it (a `danger-full-access` default would advertise nothing
|
||||
* while a narrower-switched session stays confined with no lever).
|
||||
*/
|
||||
export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
* Validate the escalation argument pairing a tool schema cannot express:
|
||||
* `sandbox_permissions` and `justification` travel together — an approval
|
||||
* prompt without a reason, or a reason driving nothing, is a malformed ask —
|
||||
* and the justification must be a non-empty sentence.
|
||||
* @param sandboxPermissions - the raw `sandbox_permissions` argument, if given.
|
||||
* @param justification - the raw `justification` argument, if given.
|
||||
*/
|
||||
export function validateEscalationArgs(sandboxPermissions: string | undefined, justification: string | undefined): void {
|
||||
if (sandboxPermissions !== undefined && justification === undefined) {
|
||||
throw new Error('invalid escalation: sandbox_permissions requires a justification')
|
||||
}
|
||||
if (justification !== undefined && sandboxPermissions === undefined) {
|
||||
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')
|
||||
}
|
||||
if (justification !== undefined && justification.trim().length === 0) {
|
||||
throw new Error('invalid justification: expected a non-empty sentence')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing denial marker — the one vocabulary both enforcing families
|
||||
* teach and report, so the model recognizes a policy denial identically
|
||||
* whether the kernel refused a bash file effect or the filesystem provider's
|
||||
* fence refused a mutation.
|
||||
* @param mode - the mode the denied call ran under.
|
||||
* @returns the marker line, exactly as the model sees it.
|
||||
*/
|
||||
export function sandboxDenialMarker(mode: SandboxMode): string {
|
||||
return `[sandbox: file access denied under ${mode} mode]`
|
||||
}
|
||||
|
||||
/**
|
||||
* The same-turn escalation hint that rides a denial when the composition
|
||||
* advertises the escalation fields — the nudge lives at the decision point so
|
||||
* the sanctioned retry does not depend on the model recalling the tool
|
||||
* description.
|
||||
* @param subject - the family's noun for the denied action (`command` for
|
||||
* bash, `operation` for a filesystem mutation).
|
||||
* @returns the hint line, exactly as the model sees it.
|
||||
*/
|
||||
export function escalationHintMarker(subject: string): string {
|
||||
return `[sandbox: escalation available — retry this exact ${subject} once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed outcome vocabulary of one escalation ask — structurally identical
|
||||
* to the approval seam's `ApprovalOutcome` so an `ApprovalService.request`
|
||||
* return is assignable without this package importing it.
|
||||
*/
|
||||
export type EscalationOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
/**
|
||||
* The minimal approval-request shape {@link approveEscalation} needs —
|
||||
* structurally the approval seam's `ApprovalService`, generic over the agent
|
||||
* type `A` and call-id type `C` so this package resolves escalations through
|
||||
* `ctx.approval` without importing the approval or agent packages (the tool
|
||||
* layer infers `A`/`C` as its own `Agent`/`CallId`).
|
||||
*/
|
||||
export interface EscalationApprover<A = object, C = string> {
|
||||
/**
|
||||
* Ask the human to approve one action, resolving to a closed outcome.
|
||||
* @param req - the audit-self-contained request (agent, tool, call id, reason, optional signal).
|
||||
* @returns the human's decision as a closed {@link EscalationOutcome}.
|
||||
*/
|
||||
request(req: { agent: A; toolName: string; callId: C; reason: string; signal?: AbortSignal }): Promise<EscalationOutcome>
|
||||
}
|
||||
|
||||
/**
|
||||
* The approval ingredients an escalating tool hands {@link approveEscalation}:
|
||||
* the approval requester (`ctx.approval`, or `undefined` when none is
|
||||
* composed), the calling agent (or `undefined` for an agent-less execution),
|
||||
* and the call's identity. The tool layer holds all of these; this package
|
||||
* only judges them.
|
||||
*/
|
||||
export interface EscalationApproval<A = object, C = string> {
|
||||
/** The approval requester (`ctx.approval`), or `undefined` when none is composed. */
|
||||
approver: EscalationApprover<A, C> | undefined
|
||||
/** The calling agent, or `undefined` for an agent-less execution (fails closed). */
|
||||
agent: A | undefined
|
||||
/** The tool-call id the approval prompt attaches to. */
|
||||
callId: C
|
||||
/** The tool name recorded on the approval request. */
|
||||
toolName: string
|
||||
/** The tool-execution abort signal the approval request rides, when present. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** One escalation request, as {@link approveEscalation} judges it. */
|
||||
export interface EscalationRequest {
|
||||
/** The requested target mode (schema-pinned to {@link ESCALATION_TARGETS} when advertised). */
|
||||
requestedMode: string
|
||||
/** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */
|
||||
justification: string
|
||||
/** The call's effective mode (session override ?? composition default) the request must strictly widen. */
|
||||
effectiveMode: SandboxMode
|
||||
/** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */
|
||||
subject: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a sandbox-escalation request BEFORE anything executes: check strict
|
||||
* widening against the call's effective mode, then resolve the approval
|
||||
* channel, then map every outcome — the ordered fail-closed sequence both
|
||||
* enforcing families share. Returns the granted mode to stamp onto exactly
|
||||
* this call; throws the distinct verbatim text for every other path (a
|
||||
* non-widening request, a missing approval service, an agent-less execution,
|
||||
* a rejection, a cancellation, an unanswerable ask) — the tool registry turns
|
||||
* the throw into the call's isError result, and nothing has run. A
|
||||
* non-widening request never prompts a human.
|
||||
* @param request - the escalation to judge (see {@link EscalationRequest}).
|
||||
* @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}).
|
||||
* @returns the granted mode, consumed by the one call that asked.
|
||||
*/
|
||||
export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {
|
||||
const { requestedMode: mode, effectiveMode, justification, subject } = request
|
||||
// Strict widening is an EXECUTION check against the call's effective mode —
|
||||
// deliberately not a schema constraint (the enum is the closed target
|
||||
// vocabulary; the effective mode is per-call truth).
|
||||
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
|
||||
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
|
||||
}
|
||||
if (approval.approver === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)
|
||||
}
|
||||
if (approval.agent === undefined) {
|
||||
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)
|
||||
}
|
||||
// Self-contained for the audit trail: approval/asked stores this reason,
|
||||
// and the target mode is part of the grant's identity.
|
||||
const outcome = await approval.approver.request({
|
||||
agent: approval.agent,
|
||||
toolName: approval.toolName,
|
||||
callId: approval.callId,
|
||||
reason: `escalate sandbox to ${mode}: ${justification}`,
|
||||
...approval.signal ? { signal: approval.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
// The schema enum already pinned `mode` to the closed target vocabulary;
|
||||
// the check above proved it is strictly wider.
|
||||
case 'allowed-once': return mode as SandboxMode
|
||||
case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"`)
|
||||
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)
|
||||
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)
|
||||
default: return assertNever(outcome, 'EscalationOutcome')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
51
packages/sandbox/sandbox/src/roots.ts
Normal file
51
packages/sandbox/sandbox/src/roots.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The writable-root derivation shared by every enforcement dialect that
|
||||
* expresses a mode as a canonical allow-list: `workspace-write` means "the
|
||||
* workspace root plus the platform temp areas", and this module is that
|
||||
* meaning's one home. The Seatbelt profile
|
||||
* (`@deepseek-ai/dsh-sandbox-local`) and the in-process filesystem fence
|
||||
* (`@deepseek-ai/dsh-fs-sandbox`) both derive their allow-list here, so "the
|
||||
* write tool cannot write /tmp but bash can" asymmetries cannot arise between
|
||||
* them. The bwrap and Landlock dialects keep their own grant spellings (an
|
||||
* ephemeral `/tmp` mount, launcher-owned flags) — the honest per-runner
|
||||
* differences recorded in the sandbox RFC — with parity pinned by test.
|
||||
*
|
||||
* @module dsh-sandbox/roots
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { SandboxPolicy } from './index.ts'
|
||||
|
||||
/**
|
||||
* Resolve a granted root to the path the enforcement layer actually compares:
|
||||
* canonical (symlinks resolved), because both Seatbelt filters and the fs
|
||||
* fence's containment check match resolved paths — `/tmp` IS `/private/tmp`
|
||||
* on darwin, and an as-spelled grant would match nothing.
|
||||
* @param path - the root as configured or platform-reported.
|
||||
* @returns the canonical path, or the spelling as-is when resolution fails
|
||||
* (a missing root matches nothing until it exists — the conservative
|
||||
* outcome; inventing a fallback would grant a path the caller never named).
|
||||
*/
|
||||
export function canonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path)
|
||||
} catch {
|
||||
// realpathSync failed: the path (or a prefix) is missing or unreadable.
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The roots one confined execution may WRITE under — the mode's meaning as a
|
||||
* canonical, deduplicated allow-list. `read-only` allows nothing;
|
||||
* `workspace-write` allows the policy's workspace root, the host `/tmp`, and
|
||||
* the per-user platform temp dir (`os.tmpdir()` — the real temp area for
|
||||
* mkstemp-family tools; omitting it would deny what the mode promises).
|
||||
* @param policy - the file-effect policy to derive the allow-list from.
|
||||
* @returns the canonical writable roots; empty exactly under `read-only`.
|
||||
*/
|
||||
export function writableRoots(policy: SandboxPolicy): string[] {
|
||||
if (policy.mode !== 'workspace-write') return []
|
||||
return [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
|
||||
}
|
||||
111
packages/sandbox/sandbox/tests/escalation.spec.ts
Normal file
111
packages/sandbox/sandbox/tests/escalation.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tests for the shared escalation vocabulary and choreography: the strictly-
|
||||
* wider ladder, the argument-pairing validation, the model-facing markers, and
|
||||
* {@link approveEscalation}'s ordered fail-closed sequence. Both enforcing tool
|
||||
* families (`dsh-tool-bash`, `dsh-tool-fs`) delegate here, so the ordering and
|
||||
* verbatim texts are pinned once, next to the vocabulary that owns them.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ESCALATION_TARGETS,
|
||||
WIDER_MODES,
|
||||
approveEscalation,
|
||||
escalationHintMarker,
|
||||
sandboxDenialMarker,
|
||||
validateEscalationArgs,
|
||||
} from '@deepseek-ai/dsh-sandbox'
|
||||
import type { EscalationApprover, EscalationOutcome } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
describe('the strictly-wider ladder', () => {
|
||||
it('read-only escalates to either wider mode; workspace-write only to full access', () => {
|
||||
expect(WIDER_MODES['read-only']).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(WIDER_MODES['workspace-write']).toEqual(['danger-full-access'])
|
||||
expect(WIDER_MODES['danger-full-access']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the target enum is the closed set every session could escalate TO (read-only is the floor)', () => {
|
||||
expect(ESCALATION_TARGETS).toEqual(['workspace-write', 'danger-full-access'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateEscalationArgs', () => {
|
||||
it('accepts neither field, or both with a non-empty justification', () => {
|
||||
expect(() => { validateEscalationArgs(undefined, undefined) }).not.toThrow()
|
||||
expect(() => { validateEscalationArgs('workspace-write', 'because the workspace needs it') }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects one field without the other, and a blank justification', () => {
|
||||
expect(() => { validateEscalationArgs('workspace-write', undefined) }).toThrow(/requires a justification/)
|
||||
expect(() => { validateEscalationArgs(undefined, 'orphan reason') }).toThrow(/only valid together with sandbox_permissions/)
|
||||
expect(() => { validateEscalationArgs('workspace-write', ' ') }).toThrow(/non-empty sentence/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the model-facing markers', () => {
|
||||
it('the denial marker names the mode', () => {
|
||||
expect(sandboxDenialMarker('read-only')).toBe('[sandbox: file access denied under read-only mode]')
|
||||
expect(sandboxDenialMarker('workspace-write')).toBe('[sandbox: file access denied under workspace-write mode]')
|
||||
})
|
||||
|
||||
it('the hint marker names the family subject', () => {
|
||||
expect(escalationHintMarker('command')).toContain('retry this exact command once with sandbox_permissions')
|
||||
expect(escalationHintMarker('operation')).toContain('retry this exact operation once with sandbox_permissions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('approveEscalation', () => {
|
||||
const req = (over: Partial<Parameters<typeof approveEscalation>[0]> = {}) => ({
|
||||
requestedMode: 'workspace-write',
|
||||
justification: 'the user asked to write in the workspace',
|
||||
effectiveMode: 'read-only' as const,
|
||||
subject: 'command',
|
||||
...over,
|
||||
})
|
||||
/** An approver that records the request and returns a fixed outcome. */
|
||||
const approver = (outcome: EscalationOutcome, sink?: (req: unknown) => void): EscalationApprover => ({
|
||||
request: async (request) => { sink?.(request); return outcome },
|
||||
})
|
||||
const ingredients = (over: Partial<Parameters<typeof approveEscalation>[1]> = {}) => ({
|
||||
approver: approver('allowed-once'),
|
||||
agent: {},
|
||||
callId: 'call-1',
|
||||
toolName: 'bash',
|
||||
...over,
|
||||
})
|
||||
|
||||
it('grants: returns the requested mode, asking through the approver with the audit reason', async () => {
|
||||
const seen: { reason?: string }[] = []
|
||||
const granted = await approveEscalation(req(), ingredients({ approver: approver('allowed-once', r => seen.push(r as { reason?: string })) }))
|
||||
expect(granted).toBe('workspace-write')
|
||||
expect(seen[0]?.reason).toBe('escalate sandbox to workspace-write: the user asked to write in the workspace')
|
||||
})
|
||||
|
||||
it('a non-widening request fails closed with its own text and never asks', async () => {
|
||||
const seen: unknown[] = []
|
||||
const spy = ingredients({ approver: approver('allowed-once', r => seen.push(r)) })
|
||||
await expect(approveEscalation(req({ requestedMode: 'read-only' }), spy))
|
||||
.rejects.toThrow(/not strictly wider than this call's current "read-only" mode/)
|
||||
await expect(approveEscalation(req({ requestedMode: 'workspace-write', effectiveMode: 'danger-full-access' as never }), spy))
|
||||
.rejects.toThrow(/not strictly wider/)
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
it('a missing approval service and an agent-less call each fail closed with distinct text', async () => {
|
||||
await expect(approveEscalation(req(), ingredients({ approver: undefined }))).rejects.toThrow(/no approval service is composed/)
|
||||
await expect(approveEscalation(req(), ingredients({ agent: undefined }))).rejects.toThrow(/no agent to route it through/)
|
||||
})
|
||||
|
||||
it('maps each non-grant outcome to its distinct verbatim text (subject in the rejection)', async () => {
|
||||
await expect(approveEscalation(req({ subject: 'operation' }), ingredients({ approver: approver('rejected') })))
|
||||
.rejects.toThrow('the user rejected escalating this operation to "workspace-write"')
|
||||
await expect(approveEscalation(req(), ingredients({ approver: approver('cancelled') })))
|
||||
.rejects.toThrow('approval for escalating to "workspace-write" was cancelled')
|
||||
await expect(approveEscalation(req(), ingredients({ approver: approver('unavailable') })))
|
||||
.rejects.toThrow('no approval channel is available')
|
||||
})
|
||||
|
||||
it('an outcome outside the closed union trips the exhaustiveness guard (defensive)', async () => {
|
||||
await expect(approveEscalation(req(), ingredients({ approver: approver('bogus' as never) }))).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
39
packages/sandbox/sandbox/tests/roots.spec.ts
Normal file
39
packages/sandbox/sandbox/tests/roots.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Tests for the writable-root derivation: the mode's meaning as a canonical
|
||||
* allow-list. Pinned here so the fs fence and the Seatbelt profile — both
|
||||
* deriving from `writableRoots` — cannot drift.
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
describe('canonicalPath', () => {
|
||||
it('resolves symlinks (an existing path realpaths)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-'))
|
||||
expect(canonicalPath(dir)).toBe(realpathSync(dir))
|
||||
})
|
||||
|
||||
it('returns the spelling as-is when the path cannot be resolved (conservative — matches nothing until it exists)', () => {
|
||||
expect(canonicalPath('/does/not/exist/anywhere-xyz')).toBe('/does/not/exist/anywhere-xyz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('writableRoots', () => {
|
||||
it('read-only grants nothing', () => {
|
||||
expect(writableRoots({ mode: 'read-only', workspaceRoot: process.cwd() })).toEqual([])
|
||||
})
|
||||
|
||||
it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-'))
|
||||
const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
|
||||
expect(roots).toContain(realpathSync(ws))
|
||||
expect(roots).toContain(canonicalPath('/tmp'))
|
||||
expect(roots).toContain(realpathSync(tmpdir()))
|
||||
// Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide).
|
||||
expect(new Set(roots).size).toBe(roots.length)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user