返回源码地图

packages/sandbox/sandbox/src/escalation.ts

main snapshot · da00f7f5358f · 正文引用章节 10 / 10;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * The escalation vocabulary and choreography shared by every sandbox-enforcing
3 * tool family (`@deepseek-ai/dsh-tool-bash`, `@deepseek-ai/dsh-tool-fs`): the
4 * strictly-wider ladder, the argument-pairing validation, the model-facing
5 * denial/hint markers, and {@link approveEscalation} — the ordered fail-closed
6 * sequence that resolves a `sandbox_permissions` request through a
7 * user-approval channel BEFORE anything executes. One home keeps the two
8 * 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, the
12 * call id, and the tool name — closes over `ctx.approval.request(...)` and
13 * hands the closure down, so this package never depends on the approval or
14 * agent packages.
15 *
16 * @module dsh-sandbox/escalation
17 */
18
19import { assertNever } from '@deepseek-ai/dsh-util-values'
20import type { SandboxMode } from './index.ts'
21
22/**
23 * The strictly-wider table: what a call whose effective mode is the key may
24 * escalate TO. Checked at EXECUTION, never baked into a tool schema — the
25 * schema's enum is {@link ESCALATION_TARGETS}, because schemas are
26 * registry-global while the effective mode is per-call truth.
27 */
28export const WIDER_MODES: Record<string, readonly SandboxMode[]> = {
29 'read-only': ['workspace-write', 'danger-full-access'],
30 'workspace-write': ['danger-full-access'],
31}
32
33/**
34 * The closed escalation-target vocabulary — every mode a call could ever
35 * escalate TO (`read-only` is the floor; nothing escalates to it). Advertised
36 * whenever the mounted capability confines: cutting the enum down to the modes
37 * wider than the composition's DEFAULT would strand a session whose effective
38 * mode sits below it (a `danger-full-access` default would advertise nothing
39 * while a narrower-switched session stays confined with no lever).
40 */
41export const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access']
42
43/**
44 * Validate the escalation argument pairing a tool schema cannot express:
45 * `sandbox_permissions` and `justification` travel together — an approval
46 * 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 */
51export 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}
62
63/**
64 * The model-facing denial marker — the one vocabulary both enforcing families
65 * teach and report, so the model recognizes a policy denial identically
66 * whether the kernel refused a bash file effect or the filesystem provider's
67 * 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 */
71export function sandboxDenialMarker(mode: SandboxMode): string {
72 return `[sandbox: file access denied under ${mode} mode]`
73}
74
75/**
76 * The same-turn escalation hint that rides a denial when the composition
77 * advertises the escalation fields — the nudge lives at the decision point so
78 * the sanctioned retry does not depend on the model recalling the tool
79 * description.
80 * @param subject - the family's noun for the denied action (`command` for
81 * bash, `operation` for a filesystem mutation).
82 * @returns the hint line, exactly as the model sees it.
83 */
84export 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}
87
88/**
89 * The model-facing `sandbox_permissions` parameter description, which carries
90 * the escalation rules for every enforcing family.
91 * @param subject - the family's noun for the denied action (`command` for
92 * bash, `operation` for a filesystem mutation).
93 * @returns the parameter description, exactly as the model sees it.
94 */
95export 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}
98
99/**
100 * The closed outcome vocabulary of one escalation ask — structurally identical
101 * to the approval seam's `ApprovalOutcome` so an `ApprovalService.request`
102 * return is assignable without this package importing it.
103 */
104export type EscalationOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
105
106/**
107 * The minimal approval-request shape {@link approveEscalation} needs —
108 * structurally the approval seam's `ApprovalService`, generic over the agent
109 * type `A` and call-id type `C` so this package resolves escalations through
110 * `ctx.approval` without importing the approval or agent packages (the tool
111 * layer infers `A`/`C` as its own `Agent`/`ToolCallId`).
112 */
113export 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: A
121 toolName: string
122 callId: C
123 reason: string
124 displayReason?: { readonly en: string; readonly [locale: string]: string }
125 signal?: AbortSignal
126 }): Promise<EscalationOutcome>
127}
128
129/**
130 * The approval ingredients an escalating tool hands {@link approveEscalation}:
131 * the approval requester (`ctx.approval`, or `undefined` when none is
132 * 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 package
134 * only judges them.
135 */
136export interface EscalationApproval<A = object, C = string> {
137 /** The approval requester (`ctx.approval`), or `undefined` when none is composed. */
138 approver: EscalationApprover<A, C> | undefined
139 /** The calling agent, or `undefined` for an agent-less execution (fails closed). */
140 agent: A | undefined
141 /** The tool-call id the approval prompt attaches to. */
142 callId: C
143 /** The tool name recorded on the approval request. */
144 toolName: string
145 /** The tool-execution abort signal the approval request rides, when present. */
146 signal?: AbortSignal
147}
148
149/** One escalation request, as {@link approveEscalation} judges it. */
150export interface EscalationRequest {
151 /** The requested target mode (schema-pinned to {@link ESCALATION_TARGETS} when advertised). */
152 requestedMode: string
153 /** The model's one-sentence reason, shown verbatim to the user inside the audit reason. */
154 justification: string
155 /** The call's effective mode (session override ?? composition default); repeating it needs no approval. */
156 effectiveMode: SandboxMode
157 /** The family's noun for the escalated action in user-facing texts (`command` for bash, `operation` for fs). */
158 subject: string
159}
160
161/**
162 * Resolve a sandbox permission request before execution. Repeating the call's
163 * effective mode returns it without approval. A strictly wider mode requires
164 * approval and applies only to this call. Narrower or unsupported targets,
165 * missing approval services or agents for widening, and non-grant outcomes
166 * 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 */
171export async function approveEscalation<A, C>(request: EscalationRequest, approval: EscalationApproval<A, C>): Promise<SandboxMode> {
172 const { requestedMode: mode, effectiveMode, justification, subject } = request
173 if (mode === effectiveMode) return effectiveMode
174 // Strict widening is an EXECUTION check against the call's effective mode —
175 // deliberately not a schema constraint (the enum is the closed target
176 // 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 SandboxMode
203 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}