1
/**2
* The escalation vocabulary and choreography shared by every sandbox-enforcing3
* tool family (`@deepseek-ai/dsh-tool-bash`, `@deepseek-ai/dsh-tool-fs`): the4
* strictly-wider ladder, the argument-pairing validation, the model-facing5
* denial/hint markers, and {@link approveEscalation} — the ordered fail-closed6
* sequence that resolves a `sandbox_permissions` request through a7
* user-approval channel BEFORE anything executes. One home keeps the two8
* families' approval ordering and verbatim error texts from drifting apart.9
*10
* The channel is a minimal STRUCTURAL function shape ({@link EscalationAsk}),11
* not the approval service type: the tool layer — which owns the agent, the12
* call id, and the tool name — closes over `ctx.approval.request(...)` and13
* hands the closure down, so this package never depends on the approval or14
* agent packages.15
*16
* @module dsh-sandbox/escalation17
*/19
import { assertNever } from '@deepseek-ai/dsh-util-values'20
import type { SandboxMode } from './index.ts'22
/**23
* The strictly-wider table: what a call whose effective mode is the key may24
* escalate TO. Checked at EXECUTION, never baked into a tool schema — the25
* schema's enum is {@link ESCALATION_TARGETS}, because schemas are26
* registry-global while the effective mode is per-call truth.27
*/28
export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {29
'read-only': ['workspace-write', 'danger-full-access'],30
'workspace-write': ['danger-full-access'],31
}33
/**34
* The closed escalation-target vocabulary — every mode a call could ever35
* escalate TO (`read-only` is the floor; nothing escalates to it). Advertised36
* whenever the mounted capability confines: cutting the enum down to the modes37
* wider than the composition's DEFAULT would strand a session whose effective38
* mode sits below it (a `danger-full-access` default would advertise nothing39
* while a narrower-switched session stays confined with no lever).40
*/41
export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']43
/**44
* Validate the escalation argument pairing a tool schema cannot express:45
* `sandbox_permissions` and `justification` travel together — an approval46
* prompt without a reason, or a reason driving nothing, is a malformed ask —47
* and the justification must be a non-empty sentence.48
* @param sandboxPermissions - the raw `sandbox_permissions` argument, if given.49
* @param justification - the raw `justification` argument, if given.50
*/51
export function validateEscalationArgs(sandboxPermissions: string | undefined, justification: string | undefined): void {52
if (sandboxPermissions !== undefined && justification === undefined) {53
throw new Error('invalid escalation: sandbox_permissions requires a justification')54
}55
if (justification !== undefined && sandboxPermissions === undefined) {56
throw new Error('invalid escalation: justification is only valid together with sandbox_permissions')57
}58
if (justification !== undefined && justification.trim().length === 0) {59
throw new Error('invalid justification: expected a non-empty sentence')60
}61
}63
/**64
* The model-facing denial marker — the one vocabulary both enforcing families65
* teach and report, so the model recognizes a policy denial identically66
* whether the kernel refused a bash file effect or the filesystem provider's67
* fence refused a mutation.68
* @param mode - the mode the denied call ran under.69
* @returns the marker line, exactly as the model sees it.70
*/71
export function sandboxDenialMarker(mode: SandboxMode): string {72
return `[sandbox: file access denied under ${mode} mode]`73
}75
/**76
* The same-turn escalation hint that rides a denial when the composition77
* advertises the escalation fields — the nudge lives at the decision point so78
* the sanctioned retry does not depend on the model recalling the tool79
* description.80
* @param subject - the family's noun for the denied action (`command` for81
* bash, `operation` for a filesystem mutation).82
* @returns the hint line, exactly as the model sees it.83
*/84
export function escalationHintMarker(subject: string): string {85
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]`86
}88
/**89
* The model-facing `sandbox_permissions` parameter description, which carries90
* the escalation rules for every enforcing family.91
* @param subject - the family's noun for the denied action (`command` for92
* bash, `operation` for a filesystem mutation).93
* @returns the parameter description, exactly as the model sees it.94
*/95
export function sandboxPermissionsDescription(subject: string): string {96
return `The narrowest wider sandbox mode for a one-shot retry of the exact ${subject} the sandbox just denied; the retry asks the user for approval.`97
}99
/**100
* The closed outcome vocabulary of one escalation ask — structurally identical101
* to the approval seam's `ApprovalOutcome` so an `ApprovalService.request`102
* return is assignable without this package importing it.103
*/104
export type EscalationOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'106
/**107
* The minimal approval-request shape {@link approveEscalation} needs —108
* structurally the approval seam's `ApprovalService`, generic over the agent109
* type `A` and call-id type `C` so this package resolves escalations through110
* `ctx.approval` without importing the approval or agent packages (the tool111
* layer infers `A`/`C` as its own `Agent`/`ToolCallId`).112
*/113
export interface EscalationApprover<A = object, C = string> {114
/**115
* Ask the human to approve one action, resolving to a closed outcome.116
* @param req - the audit request with optional localized displayReason and presentation lifetime signal.117
* @returns the human's decision as a closed {@link EscalationOutcome}.118
*/119
request(req: {120
agent: A121
toolName: string122
callId: C123
reason: string124
displayReason?: { readonly en: string; readonly [locale: string]: string }125
signal?: AbortSignal126
}): Promise<EscalationOutcome>127
}129
/**130
* The approval ingredients an escalating tool hands {@link approveEscalation}:131
* the approval requester (`ctx.approval`, or `undefined` when none is132
* composed), the calling agent (or `undefined` for an agent-less execution),133
* and the call's identity. The tool layer holds all of these; this package134
* only judges them.135
*/136
export interface EscalationApproval<A = object, C = string> {137
/** The approval requester (`ctx.approval`), or `undefined` when none is composed. */138
approver: EscalationApprover<A, C> | undefined139
/** The calling agent, or `undefined` for an agent-less execution (fails closed). */140
agent: A | undefined141
/** The tool-call id the approval prompt attaches to. */142
callId: C143
/** The tool name recorded on the approval request. */144
toolName: string145
/** The tool-execution abort signal the approval request rides, when present. */146
signal?: AbortSignal147
}149
/** One escalation request, as {@link approveEscalation} judges it. */150
export interface EscalationRequest {151
/** The requested target mode (schema-pinned to {@link ESCALATION_TARGETS} when advertised). */152
requestedMode: string153
/** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */154
justification: string155
/** The call's effective mode (session override ?? composition default); repeating it needs no approval. */156
effectiveMode: SandboxMode157
/** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */158
subject: string159
}161
/**162
* Resolve a sandbox permission request before execution. Repeating the call's163
* effective mode returns it without approval. A strictly wider mode requires164
* approval and applies only to this call. Narrower or unsupported targets,165
* missing approval services or agents for widening, and non-grant outcomes166
* throw before execution.167
* @param request - the escalation to judge (see {@link EscalationRequest}).168
* @param approval - the approval ingredients the tool holds (see {@link EscalationApproval}).169
* @returns the granted mode, consumed by the one call that asked.170
*/171
export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {172
const { requestedMode: mode, effectiveMode, justification, subject } = request173
if (mode === effectiveMode) return effectiveMode174
// Strict widening is an EXECUTION check against the call's effective mode —175
// deliberately not a schema constraint (the enum is the closed target176
// vocabulary; the effective mode is per-call truth).177
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {178
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)179
}180
if (approval.approver === undefined) {181
throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval service is composed`)182
}183
if (approval.agent === undefined) {184
throw new Error(`sandbox escalation to "${mode}" requires approval, but the call has no agent to route it through`)185
}186
// Self-contained for the audit trail: approval/asked stores this reason,187
// and the target mode is part of the grant's identity.188
const outcome = await approval.approver.request({189
agent: approval.agent,190
toolName: approval.toolName,191
callId: approval.callId,192
reason: `escalate sandbox to ${mode}: ${justification}`,193
displayReason: {194
en: `Allow this operation with ${mode} permissions: ${justification}`,195
zh: `允许本次操作使用 ${mode} 权限:${justification}`,196
},197
...approval.signal ? { signal: approval.signal } : {},198
})199
switch (outcome) {200
// The schema enum already pinned `mode` to the closed target vocabulary;201
// the check above proved it is strictly wider.202
case 'allowed-once': return mode as SandboxMode203
case 'rejected': throw new Error(`the user rejected escalating this ${subject} to "${mode}"; it stays denied, so stop and explain instead of working around it`)204
case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`)205
case 'unavailable': throw new Error(`sandbox escalation to "${mode}" requires approval, but no approval channel is available`)206
default: return assertNever(outcome, 'EscalationOutcome')207
}208
}