返回源码地图

packages/sandbox/sandbox-local/src/index.ts

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

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

1/**
2 * Local sandbox backend. It selects the platform runner chain (Linux bwrap then
3 * Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes
4 * competing candidates once, and reports each wrap's enforcement and stderr
5 * classification facts. Missing or unusable confinement fails closed rather
6 * than returning the original argv.
7 *
8 * The windows-acl rung additionally owns the write grants: the write SID is
9 * the per-WORKSPACE identity derived from the canonical workspace path
10 * (`workspaceWriteSid`), while every live session receives a RANDOM private
11 * temp directory and its own derived capability (`tempWriteSid`). The
12 * workspace-root ACE materializes once per workspace per server lifetime
13 * and STANDS (the cross-session reuse cache — the exact-ACE skip makes
14 * every later provision O(1) instead of re-propagating the tree per
15 * session); the private-temp ACEs are revoked on dispose. The runner
16 * receives both SIDs (their presence marks the seam-managed contract) and
17 * stops managing DACLs itself. The rung reports partial enforcement because
18 * NTFS hard links alias one file object across paths, reads stay unconfined,
19 * and a tree another AppContainer tool has ACL'd with a package SID is not
20 * readable by the Low-integrity child.
21 * @module @deepseek-ai/dsh-sandbox-local
22 */
23
24import { spawnSync } from 'node:child_process'
25import { existsSync, mkdtempSync, rmSync } from 'node:fs'
26import { tmpdir } from 'node:os'
27import { join } from 'node:path'
28import { fileURLToPath } from 'node:url'
29import {
30 LAUNCHER_BIN,
31 LAUNCHER_FAILURE_EXIT,
32 launcherPath as landlockLauncherPath,
33 probe as defaultProbeLandlock,
34} from '@deepseek-ai/node-addon-system/landlock-run'
35import { Context } from '@deepseek-ai/cordis'
36import z from '@deepseek-ai/schemastery'
37import { SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox'
38import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
39import type { SessionId } from '@deepseek-ai/dsh-session'
40import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
41import { assertNever } from '@deepseek-ai/dsh-util-values'
42import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
43
44/** Plugin config. All optional — `static Config` supplies the defaults. */
45export interface Config {
46 /**
47 * Override the runner argv; bwrap-compatible profile arguments are appended. A
48 * non-empty override asserts full enforcement and skips built-in selection and
49 * probing. A runner that starts but refuses its profile must be identifiable by
50 * {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after
51 * confirming the workdir is usable. `ENOENT` or `EACCES` identifies the runner when
52 * `error.path` equals argv[0] and `error.syscall` is `spawn` or `spawn <runner>`, or
53 * when `error.path` is absent and `error.syscall` is exactly `spawn <runner>`.
54 */
55 runnerCommand?: string[]
56 /**
57 * Case-insensitive stderr substrings emitted when a configured
58 * {@link runnerCommand} refuses its profile before executing the wrapped
59 * command. Required and non-empty with `runnerCommand`; rejected without
60 * it. Each entry is a non-empty, single-line, case-insensitive substring
61 * covering the executable runner's own failure dialect.
62 */
63 runnerFailureSignatures?: string[]
64 /** Positive timeout for each functional probe; zero would mean unbounded to Node. */
65 probeTimeoutMs?: number
66}
67
68/** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */
69function defaultProbeBwrap(timeoutMs: number): boolean {
70 const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], {
71 timeout: timeoutMs,
72 stdio: 'ignore',
73 })
74 return probe.status === 0
75}
76
77/**
78 * Functional Seatbelt probe: apply the real `read-only` profile through
79 * `sandbox-exec -p` and run `true` under it — exit 0 means the kernel
80 * accepted and enforced the profile (`sandbox-exec` exits non-zero when
81 * `sandbox_init` refuses it). A missing `sandbox-exec` (every non-macOS
82 * host) fails the spawn and probes `unusable`, exactly like the other
83 * rungs' absent binaries. Apple marks the CLI deprecated but ships it on
84 * every macOS; if it ever disappears, this probe is what fails closed.
85 */
86function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean {
87 const probe = spawnSync(seatbeltExec, [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], {
88 timeout: timeoutMs,
89 stdio: 'ignore',
90 })
91 return probe.status === 0
92}
93
94/**
95 * Functional windows-acl probe: run the runner in read-only mode (zero grants,
96 * no ACL mutation) around `cmd /c exit 0` — exit 0 means the runner created
97 * the restricted token and spawned the child under it. The win32 chain is a
98 * sole candidate, so the product never probes; the probe exists for override
99 * chains and mirrors the other rungs' shape.
100 */
101function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): boolean {
102 const program = runnerInvocation[0]
103 if (program === undefined) return false
104 const probe = spawnSync(program, [
105 ...runnerInvocation.slice(1),
106 '--workspace', tmpdir(), '--temp', tmpdir(), '--mode', 'read-only',
107 '--', 'cmd', '/c', 'exit', '0',
108 ], {
109 timeout: timeoutMs,
110 stdio: 'ignore',
111 })
112 return probe.status === 0
113}
114
115/** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */
116export interface SandboxInternals {
117 /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */
118 platform?: string
119 /** Replaces the platform's chain wholesale (walk mechanics — e.g. probing a rung the product chains only reach unprobed). */
120 chain?: readonly SelectedRunner['runner'][]
121 /** Replaces the functional `bwrap` probe (the Linux chain's first rung). */
122 probeBwrap?: () => boolean
123 /** Replaces the functional Landlock launcher probe (the Linux chain's second rung). */
124 probeLandlock?: (launcher: string) => SandboxEnforcement | 'unusable'
125 /** Replaces the functional Seatbelt probe (the darwin chain's sole rung — only consulted if that chain ever grows). */
126 probeSeatbelt?: (seatbeltExec: string) => boolean
127 /** Replaces the resolved `landlock-run` launcher path (a fake launcher script). */
128 landlockLauncher?: string
129 /** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */
130 seatbeltExec?: string
131 /** Replaces the resolved windows-acl runner argv prefix (a fake runner). */
132 windowsAclRunnerArgs?: string[]
133 /** Replaces the resolved windows-acl runner built entry path (a fake lib/runner.js location). */
134 windowsAclRunnerEntry?: string
135 /** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */
136 probeWindowsAcl?: () => boolean
137 /** Replaces the private-temp-directory removal at provider dispose (a throwing fake exercises the cleanup-failure path). */
138 rmTempDir?: (path: string) => void
139}
140
141/** The chain's verdict: which runner confines, and how completely it enforces. */
142type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement }
143
144/** One live session/workspace pair's private temp directory and capability. */
145interface AclTempCapability {
146 dir: string
147 writeSid: string
148 grant: AclWriteGrant
149}
150
151/**
152 * The runner chain per platform — selection is BY PLATFORM first, probes
153 * second: a platform's chain is probed in preference order only when it has
154 * MORE than one candidate (probing arbitrates; it does not re-validate a
155 * choice that has no alternative). A platform with no chain fails closed at
156 * `confine()`. Linux prefers `bwrap` (its mount profile is closest to the
157 * mode vocabulary) over the Landlock launcher; darwin has exactly one
158 * candidate, selected without any probe.
159 */
160const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
161 linux: ['bwrap', 'landlock'],
162 darwin: ['seatbelt'],
163 // The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl):
164 // a sole candidate, selected without a probe — its execution-time refusal
165 // fails closed through its stderr signature (windows-acl-run:) and exit 127.
166 win32: ['windows-acl'],
167}
168
169/**
170 * Enforcement completeness a rung claims when selected WITHOUT a probe (a
171 * chain of one). `bwrap` and Seatbelt govern every promised file effect by
172 * construction, so the claim is a profile fact; `landlock` is listed for the
173 * table's totality but is unreachable without a probe (the Linux chain has
174 * two rungs, so it is only ever selected through its probe, whose report is
175 * what distinguishes full from per-ABI-partial — and the launcher additionally
176 * self-reports partial enforcement on stderr at every confined run).
177 */
178const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> = {
179 bwrap: 'full',
180 landlock: 'full',
181 seatbelt: 'full',
182 // Everyone stays in both restricting lists for process initialization, but
183 // the Low label denies the write authority it used to confer. NTFS hard
184 // links still alias a granted workspace file to a path outside it, reads
185 // stay unconfined, and an AppContainer-ACL'd tree is unreadable to the
186 // child: the backend enforces the remaining ACL-addressable surface but
187 // must not advertise the absolute promise.
188 'windows-acl': 'partial',
189}
190
191/**
192 * A probe bound must be a positive finite number: Node treats
193 * `spawnSync({ timeout: 0 })` as NO timeout, so an unvalidated 0 would
194 * silently mean "unbounded" — the opposite of what the field promises.
195 */
196function assertPositiveFinite(name: string, value: number): void {
197 if (!Number.isFinite(value) || value <= 0) {
198 throw new Error(`sandbox-local: ${name} must be a positive finite number`)
199 }
200}
201
202/**
203 * The denial dialect each runner's kernel speaks — the case-insensitive stderr substrings a
204 * denied file effect produces under it, carried on every wrap (the seam's
205 * `ConfinedArgv.denialSignatures`).
206 */
207const DENIAL_SIGNATURES = {
208 bwrap: ['read-only file system'],
209 landlock: ['permission denied'],
210 seatbelt: ['operation not permitted'],
211 // pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied.";
212 // Node EACCES: "permission denied"; EPERM: "operation not permitted".
213 'windows-acl': ['access is denied', 'access to the path', 'permission denied', 'operation not permitted'],
214 runnerCommand: ['read-only file system', 'permission denied'],
215} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
216
217/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */
218const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127
219
220/**
221 * Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus
222 * fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit
223 * 1 but its public contract does not reserve that status, while sandbox-exec
224 * publishes no launcher-failure status; those backends remain signature-only.
225 * The windows-acl runner prints `windows-acl-run: <detail>` on every
226 * runner-side failure and exits 127 — the rule is exit-gated on that status
227 * so a confined command that merely PRINTS the signature (or a runner
228 * cleanup failure reported on a non-zero child exit) is never misclassified
229 * as "the command did not run". Keep the Landlock tuple aligned with the
230 * assembled snapshot fixture at
231 * `packages/test-support/session-snapshot/tests/fixtures/partial-landlock-sandbox.ts`.
232 */
233const RUNNER_FAILURE_RULES = {
234 bwrap: [{ fatalSignatures: ['bwrap: '] }],
235 landlock: [{
236 allowedExitCodes: [LAUNCHER_FAILURE_EXIT],
237 fatalSignatures: [`${LAUNCHER_BIN}: `],
238 informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`],
239 }],
240 seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }],
241 'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }],
242} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]>
243
244/**
245 * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
246 * chain verdict and, on the windows-acl rung, the write grants
247 * ({@link AclWriteGrant}: the standing workspace-root grant per workspace
248 * and the revocable private-temp grant per live session/workspace pair, the
249 * latter revoked on provider dispose); the one-time probes spawn nothing
250 * else.
251 */
252export class LocalSandboxProvider extends SandboxProvider {
253 // Inline schema call: the config catalog walks `static Config` statically.
254 static Config: z<Config> = z.object({
255 runnerCommand: z.array(z.string()).default([]),
256 runnerFailureSignatures: z.array(z.string()).default([]),
257 probeTimeoutMs: z.natural().default(5_000),
258 })
259
260 /** Test hook (mirrors the bash executors' `internals`). */
261 internals: SandboxInternals = {}
262
263 private readonly runnerCommand: string[] | undefined
264 private readonly configuredRunnerFailureSignatures: string[]
265 private readonly probeTimeoutMs: number
266 /** Cached chain verdict; undefined until the first confined wrap needs it. */
267 private selectedRunner: SelectedRunner | 'unavailable' | undefined
268 /**
269 * Server-lifetime write grants (windows-acl rung): the STANDING
270 * workspace-root grant per workspace (its ACE is the cross-session reuse
271 * cache and outlives the provider — never revoked) and the REVOCABLE
272 * private-temp grant per live session/workspace pair (revoked on provider
273 * dispose).
274 */
275 private readonly workspaceGrants = new Map<string, AclWriteGrant>()
276 private readonly tempCapabilities = new Map<string, AclTempCapability>()
277
278 constructor(ctx: Context, config: Config) {
279 super(ctx)
280 // The schema (static Config) defaults every field — the casts record
281 // those runtime facts. An empty runnerCommand means "not configured":
282 // use the platform chain.
283 const runner = config.runnerCommand as string[]
284 const runnerFailureSignatures = config.runnerFailureSignatures as string[]
285 if (runner.length === 0 && runnerFailureSignatures.length > 0) {
286 throw new Error('sandbox-local: runnerFailureSignatures requires runnerCommand')
287 }
288 if (runner.length > 0 && runnerFailureSignatures.length === 0) {
289 throw new Error('sandbox-local: runnerCommand requires at least one runnerFailureSignatures entry')
290 }
291 if (runnerFailureSignatures.some(signature => signature.trim().length === 0 || /[\r\n]/u.test(signature))) {
292 throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty single-line strings')
293 }
294 this.runnerCommand = runner.length > 0 ? runner : undefined
295 this.configuredRunnerFailureSignatures = runnerFailureSignatures
296 this.probeTimeoutMs = config.probeTimeoutMs as number
297 assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
298 // An operator-supplied runner does not use the ACL backend. The registry
299 // remains optional and may be mounted after this provider.
300 /* v8 ignore next 3 -- Windows-only registration; the Linux coverage lane cannot take this branch */
301 if (process.platform === 'win32' && this.runnerCommand === undefined) {
302 ctx.inject(['skills'], (skillsCtx) => { registerAclDiagnosisSkill(skillsCtx) })
303 }
304 // The temp grants are revoked with the provider: a clean server
305 // shutdown leaves no temp ACEs behind (workspace ACEs stand by design —
306 // the reuse cache; an unclean shutdown leaves them for the next
307 // provision's exact-ACE skip).
308 ctx.effect(() => () => {
309 this.revokeAclGrants()
310 })
311 }
312
313 /**
314 * Wrap `argv` in the selected runner's invocation for `policy` — the configured
315 * `runnerCommand` when present (the operator's assertion, no probe), else the platform
316 * chain's runner speaking its own profile dialect.
317 *
318 * @param argv - the exact argv the caller is about to spawn.
319 * @param policy - the file-effect policy this execution runs under.
320 * @param signal - cancellation before policy resolution or grant creation.
321 * @returns the wrapped argv plus the selected backend's enforcement completeness, denial
322 * signatures, and structured runner-failure rules; throws the fail-closed
323 * `SANDBOX_UNAVAILABLE` error when the platform has no usable runner.
324 */
325 async confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv> {
326 signal?.throwIfAborted()
327 policy = { ...policy, workspaceRoot: canonicalPath(policy.workspaceRoot) }
328 if (this.runnerCommand !== undefined) {
329 return Promise.resolve<ConfinedArgv>({
330 argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
331 enforcement: 'full',
332 denialSignatures: DENIAL_SIGNATURES.runnerCommand,
333 runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }],
334 })
335 }
336 const selected = this.selectRunner(policy.mode)
337 const runnerArgv = this.runnerArgv(selected.runner, policy)
338 return Promise.resolve<ConfinedArgv>({
339 argv: [...runnerArgv, '--', ...argv],
340 enforcement: selected.enforcement,
341 denialSignatures: DENIAL_SIGNATURES[selected.runner],
342 runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner],
343 })
344 }
345
346 /** The selected rung's runner invocation (program + profile arguments) for one policy. */
347 private runnerArgv(runner: SelectedRunner['runner'], policy: SandboxPolicy): string[] {
348 switch (runner) {
349 case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)]
350 case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)]
351 case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)]
352 case 'windows-acl': return this.windowsAclRunnerArgv(policy)
353 default: return assertNever(runner)
354 }
355 }
356
357 /**
358 * The windows-acl runner argv for one policy. With a calling session (the
359 * policy's `sessionId`) under workspace-write, the grants are materialized
360 * once per provider lifetime — the standing workspace-root grant per
361 * workspace and a revocable, RANDOM private-temp capability per live
362 * session/workspace pair. The runner receives `--write-sid` plus
363 * `--temp-write-sid` and grants nothing itself. Agentless workspace-write
364 * calls pass the ambient temp ROOT and no SID flags: the runner creates and
365 * removes a random private child directory for that one invocation.
366 * @param policy - the resolved per-call policy.
367 * @returns the runner invocation.
368 */
369 private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
370 const sessionId = policy.sessionId
371 if (sessionId === undefined || policy.mode === 'read-only') {
372 return [
373 ...this.windowsAclRunnerInvocation(),
374 '--workspace', policy.workspaceRoot,
375 '--temp', tmpdir(),
376 '--mode', policy.mode,
377 ]
378 }
379 const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot)
380 return [
381 ...this.windowsAclRunnerInvocation(),
382 '--workspace', policy.workspaceRoot,
383 '--temp', temp.dir,
384 '--mode', policy.mode,
385 '--write-sid', workspaceWriteSid(policy.workspaceRoot),
386 '--temp-write-sid', temp.writeSid,
387 ]
388 }
389
390 /**
391 * Materialize one workspace-write policy's ACEs once per provider
392 * lifetime. The workspace SID and standing root grant are shared by the
393 * workspace. The temp directory is random and carries a distinct SID, so
394 * another session on the same workspace cannot use the shared workspace
395 * SID to enter it. A fresh provider always chooses a new path; crash
396 * residue therefore cannot collide with or authorize a resumed session.
397 * Fail-closed: a half-materialized temp grant is revoked and its directory
398 * removed before the error propagates.
399 * @param sessionId - the policy's calling-session identity.
400 * @param workspaceRoot - the resolved policy root.
401 * @returns the pair's private temp directory and write capability.
402 */
403 private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability {
404 assertTempRootOutsideWorkspace(workspaceRoot, tmpdir())
405 const writeSid = workspaceWriteSid(workspaceRoot)
406 if (!this.workspaceGrants.has(workspaceRoot)) {
407 const grant = AclWriteGrant.create(writeSid)
408 try {
409 grant.add(workspaceRoot, true)
410 } catch (error) {
411 // Free the SID; a standing ACE (if the apply succeeded before a
412 // post-apply throw) is the intended end state, not an error
413 // artifact — nothing to revoke.
414 try {
415 grant.dispose()
416 } catch (cleanupError) {
417 throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed')
418 }
419 throw error
420 }
421 this.workspaceGrants.set(workspaceRoot, grant)
422 }
423 const key = JSON.stringify([String(sessionId), workspaceRoot])
424 const existing = this.tempCapabilities.get(key)
425 if (existing !== undefined) return existing
426 const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))
427 const tempSid = tempWriteSid(tempDir)
428 let grant: AclWriteGrant | undefined
429 try {
430 grant = AclWriteGrant.create(tempSid)
431 grant.add(tempDir)
432 } catch (error) {
433 const cleanupFailures: unknown[] = []
434 if (grant !== undefined) {
435 try {
436 grant.dispose()
437 } catch (cleanupError) {
438 cleanupFailures.push(cleanupError)
439 }
440 }
441 try {
442 this.removeTempDir(tempDir)
443 } catch (cleanupError) {
444 cleanupFailures.push(cleanupError)
445 }
446 if (cleanupFailures.length > 0) {
447 throw new AggregateError([error, ...cleanupFailures], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed')
448 }
449 throw error
450 }
451 const capability = { dir: tempDir, writeSid: tempSid, grant }
452 this.tempCapabilities.set(key, capability)
453 return capability
454 }
455
456 /**
457 * Dispose every write grant (provider dispose): the revocable temp ACEs
458 * are revoked, the private temp directories this provider created are
459 * removed, and every SID allocation is freed; the standing workspace ACEs
460 * stay (the reuse cache). Cleanup failures are reported, not thrown:
461 * cordis teardown must not be aborted by grant cleanup. A crash skips all
462 * of it, but a new provider never reuses the residue's random path or SID;
463 * OS temp hygiene (or manual removal) eventually reclaims it.
464 */
465 private revokeAclGrants(): void {
466 if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return
467 const failures: unknown[] = []
468 for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) {
469 try {
470 grant.dispose()
471 } catch (error) {
472 failures.push(error)
473 }
474 }
475 for (const { dir } of this.tempCapabilities.values()) {
476 try {
477 this.removeTempDir(dir)
478 } catch (error) {
479 failures.push(error)
480 }
481 }
482 this.workspaceGrants.clear()
483 this.tempCapabilities.clear()
484 if (failures.length > 0) {
485 this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
486 for (const error of failures) this.ctx.logger.warn(error)
487 }
488 }
489
490 /** Remove one provider-owned private temp directory (injectable for cleanup tests). */
491 private removeTempDir(dir: string): void {
492 const remove = this.internals.rmTempDir ?? ((path: string) => { rmSync(path, { recursive: true, force: true }) })
493 remove(dir)
494 }
495
496 /**
497 * Resolve which runner confines commands, once, for the provider's
498 * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
499 * candidate selected directly, multiple candidates arbitrated by
500 * functional probes in chain order. Fail closed when the platform has no
501 * chain or no candidate passes — the command never runs.
502 */
503 private selectRunner(mode: ConfinedSandboxMode): SelectedRunner {
504 this.selectedRunner ??= this.chainVerdict()
505 if (this.selectedRunner === 'unavailable') throw new SandboxUnavailableError(mode)
506 return this.selectedRunner
507 }
508
509 /** Walk this platform's chain: sole candidate unprobed, several probed in order, none usable → unavailable. */
510 private chainVerdict(): SelectedRunner | 'unavailable' {
511 const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? []
512 const [first, ...rest] = chain
513 if (first === undefined) return 'unavailable'
514 // A sole candidate needs no arbitration; its execution-time refusal still fails closed.
515 if (rest.length === 0) return { runner: first, enforcement: STATIC_ENFORCEMENT[first] }
516 for (const runner of chain) {
517 const enforcement = this.probeRunner(runner)
518 if (enforcement !== 'unusable') return { runner, enforcement }
519 }
520 return 'unavailable'
521 }
522
523 /** One rung's functional probe (each at most once, via the chain walk). */
524 private probeRunner(runner: SelectedRunner['runner']): SandboxEnforcement | 'unusable' {
525 // bwrap's mount profile and Seatbelt's deny-file-write* profile govern
526 // every promised file effect by construction, so their passing probes
527 // are always full enforcement; the Landlock launcher's probe report
528 // distinguishes full from per-ABI-partial, while windows-acl is always
529 // partial for its documented hard-link, unconfined-read, and
530 // AppContainer-ACL boundaries.
531 switch (runner) {
532 case 'bwrap': {
533 const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs))
534 return probe() ? 'full' : 'unusable'
535 }
536 case 'landlock': {
537 const probe = this.internals.probeLandlock ?? (launcher => defaultProbeLandlock(launcher, { timeoutMs: this.probeTimeoutMs }))
538 return probe(this.landlockLauncher())
539 }
540 case 'seatbelt': {
541 const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs))
542 return probe(this.seatbeltExec()) ? 'full' : 'unusable'
543 }
544 case 'windows-acl': {
545 const probe = this.internals.probeWindowsAcl
546 ?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs))
547 return probe() ? 'partial' : 'unusable'
548 }
549 default: return assertNever(runner)
550 }
551 }
552
553 /** The Landlock launcher to probe and exec (test hook over the resolved one). */
554 private landlockLauncher(): string {
555 return this.internals.landlockLauncher ?? landlockLauncherPath()
556 }
557
558 /** The `sandbox-exec` executable to probe and exec (test hook over the system one). */
559 private seatbeltExec(): string {
560 return this.internals.seatbeltExec ?? 'sandbox-exec'
561 }
562
563 /**
564 * The windows-acl runner argv prefix: the built lib/runner.js entry when
565 * present (production), else the package source through tsx (development).
566 * Pin the source loader and TypeScript paths to this installation, independently
567 * of target cwd or environment overrides.
568 * The prefix stays `[node, runner, ...]` — a future native-exe runner keeps
569 * the same argv contract and only swaps these entries.
570 */
571 private windowsAclRunnerInvocation(): string[] {
572 const override = this.internals.windowsAclRunnerArgs
573 if (override !== undefined) return override
574 const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner'))
575 if (existsSync(builtEntry)) return [process.execPath, builtEntry]
576 const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts'))
577 const sourceConfig = fileURLToPath(new URL('../../../../tsconfig.base.json', import.meta.url))
578 const registration = `import { register } from ${JSON.stringify(import.meta.resolve('tsx/esm/api'))}; register({ tsconfig: ${JSON.stringify(sourceConfig)} });`
579 return [process.execPath, '--import', `data:text/javascript,${encodeURIComponent(registration)}`, sourceEntry]
580 }
581}
582
583export default LocalSandboxProvider