1
/**2
* Local sandbox backend. It selects the platform runner chain (Linux bwrap then3
* Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes4
* competing candidates once, and reports each wrap's enforcement and stderr5
* classification facts. Missing or unusable confinement fails closed rather6
* than returning the original argv.7
*8
* The windows-acl rung additionally owns the write grants: the write SID is9
* the per-WORKSPACE identity derived from the canonical workspace path10
* (`workspaceWriteSid`), while every live session receives a RANDOM private11
* temp directory and its own derived capability (`tempWriteSid`). The12
* workspace-root ACE materializes once per workspace per server lifetime13
* and STANDS (the cross-session reuse cache — the exact-ACE skip makes14
* every later provision O(1) instead of re-propagating the tree per15
* session); the private-temp ACEs are revoked on dispose. The runner16
* receives both SIDs (their presence marks the seam-managed contract) and17
* stops managing DACLs itself. The rung reports partial enforcement because18
* 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 not20
* readable by the Low-integrity child.21
* @module @deepseek-ai/dsh-sandbox-local22
*/24
import { spawnSync } from 'node:child_process'25
import { existsSync, mkdtempSync, rmSync } from 'node:fs'26
import { tmpdir } from 'node:os'27
import { join } from 'node:path'28
import { fileURLToPath } from 'node:url'29
import {30
LAUNCHER_BIN,31
LAUNCHER_FAILURE_EXIT,32
launcherPath as landlockLauncherPath,33
probe as defaultProbeLandlock,34
} from '@deepseek-ai/node-addon-system/landlock-run'35
import { Context } from '@deepseek-ai/cordis'36
import z from '@deepseek-ai/schemastery'37
import { SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox'38
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'39
import type { SessionId } from '@deepseek-ai/dsh-session'40
import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'41
import { assertNever } from '@deepseek-ai/dsh-util-values'42
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'44
/** Plugin config. All optional — `static Config` supplies the defaults. */45
export interface Config {46
/**47
* Override the runner argv; bwrap-compatible profile arguments are appended. A48
* non-empty override asserts full enforcement and skips built-in selection and49
* probing. A runner that starts but refuses its profile must be identifiable by50
* {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after51
* confirming the workdir is usable. `ENOENT` or `EACCES` identifies the runner when52
* `error.path` equals argv[0] and `error.syscall` is `spawn` or `spawn <runner>`, or53
* 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 configured58
* {@link runnerCommand} refuses its profile before executing the wrapped59
* command. Required and non-empty with `runnerCommand`; rejected without60
* it. Each entry is a non-empty, single-line, case-insensitive substring61
* 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?: number66
}68
/** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */69
function 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 === 075
}77
/**78
* Functional Seatbelt probe: apply the real `read-only` profile through79
* `sandbox-exec -p` and run `true` under it — exit 0 means the kernel80
* accepted and enforced the profile (`sandbox-exec` exits non-zero when81
* `sandbox_init` refuses it). A missing `sandbox-exec` (every non-macOS82
* host) fails the spawn and probes `unusable`, exactly like the other83
* rungs' absent binaries. Apple marks the CLI deprecated but ships it on84
* every macOS; if it ever disappears, this probe is what fails closed.85
*/86
function 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 === 092
}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 created97
* the restricted token and spawned the child under it. The win32 chain is a98
* sole candidate, so the product never probes; the probe exists for override99
* chains and mirrors the other rungs' shape.100
*/101
function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): boolean {102
const program = runnerInvocation[0]103
if (program === undefined) return false104
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 === 0113
}115
/** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */116
export interface SandboxInternals {117
/** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */118
platform?: string119
/** 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?: () => boolean123
/** 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) => boolean127
/** Replaces the resolved `landlock-run` launcher path (a fake launcher script). */128
landlockLauncher?: string129
/** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */130
seatbeltExec?: string131
/** 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?: string135
/** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */136
probeWindowsAcl?: () => boolean137
/** Replaces the private-temp-directory removal at provider dispose (a throwing fake exercises the cleanup-failure path). */138
rmTempDir?: (path: string) => void139
}141
/** The chain's verdict: which runner confines, and how completely it enforces. */142
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement }144
/** One live session/workspace pair's private temp directory and capability. */145
interface AclTempCapability {146
dir: string147
writeSid: string148
grant: AclWriteGrant149
}151
/**152
* The runner chain per platform — selection is BY PLATFORM first, probes153
* second: a platform's chain is probed in preference order only when it has154
* MORE than one candidate (probing arbitrates; it does not re-validate a155
* choice that has no alternative). A platform with no chain fails closed at156
* `confine()`. Linux prefers `bwrap` (its mount profile is closest to the157
* mode vocabulary) over the Landlock launcher; darwin has exactly one158
* candidate, selected without any probe.159
*/160
const 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 refusal165
// fails closed through its stderr signature (windows-acl-run:) and exit 127.166
win32: ['windows-acl'],167
}169
/**170
* Enforcement completeness a rung claims when selected WITHOUT a probe (a171
* chain of one). `bwrap` and Seatbelt govern every promised file effect by172
* construction, so the claim is a profile fact; `landlock` is listed for the173
* table's totality but is unreachable without a probe (the Linux chain has174
* two rungs, so it is only ever selected through its probe, whose report is175
* what distinguishes full from per-ABI-partial — and the launcher additionally176
* self-reports partial enforcement on stderr at every confined run).177
*/178
const 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, but183
// the Low label denies the write authority it used to confer. NTFS hard184
// links still alias a granted workspace file to a path outside it, reads185
// stay unconfined, and an AppContainer-ACL'd tree is unreadable to the186
// child: the backend enforces the remaining ACL-addressable surface but187
// must not advertise the absolute promise.188
'windows-acl': 'partial',189
}191
/**192
* A probe bound must be a positive finite number: Node treats193
* `spawnSync({ timeout: 0 })` as NO timeout, so an unvalidated 0 would194
* silently mean "unbounded" — the opposite of what the field promises.195
*/196
function 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
}202
/**203
* The denial dialect each runner's kernel speaks — the case-insensitive stderr substrings a204
* denied file effect produces under it, carried on every wrap (the seam's205
* `ConfinedArgv.denialSignatures`).206
*/207
const 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[]>217
/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */218
const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127220
/**221
* Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus222
* fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit223
* 1 but its public contract does not reserve that status, while sandbox-exec224
* publishes no launcher-failure status; those backends remain signature-only.225
* The windows-acl runner prints `windows-acl-run: <detail>` on every226
* runner-side failure and exits 127 — the rule is exit-gated on that status227
* so a confined command that merely PRINTS the signature (or a runner228
* cleanup failure reported on a non-zero child exit) is never misclassified229
* as "the command did not run". Keep the Landlock tuple aligned with the230
* assembled snapshot fixture at231
* `packages/test-support/session-snapshot/tests/fixtures/partial-landlock-sandbox.ts`.232
*/233
const 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[]>244
/**245
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the246
* chain verdict and, on the windows-acl rung, the write grants247
* ({@link AclWriteGrant}: the standing workspace-root grant per workspace248
* and the revocable private-temp grant per live session/workspace pair, the249
* latter revoked on provider dispose); the one-time probes spawn nothing250
* else.251
*/252
export 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
})260
/** Test hook (mirrors the bash executors' `internals`). */261
internals: SandboxInternals = {}263
private readonly runnerCommand: string[] | undefined264
private readonly configuredRunnerFailureSignatures: string[]265
private readonly probeTimeoutMs: number266
/** Cached chain verdict; undefined until the first confined wrap needs it. */267
private selectedRunner: SelectedRunner | 'unavailable' | undefined268
/**269
* Server-lifetime write grants (windows-acl rung): the STANDING270
* workspace-root grant per workspace (its ACE is the cross-session reuse271
* cache and outlives the provider — never revoked) and the REVOCABLE272
* private-temp grant per live session/workspace pair (revoked on provider273
* dispose).274
*/275
private readonly workspaceGrants = new Map<string, AclWriteGrant>()276
private readonly tempCapabilities = new Map<string, AclTempCapability>()278
constructor(ctx: Context, config: Config) {279
super(ctx)280
// The schema (static Config) defaults every field — the casts record281
// 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 : undefined295
this.configuredRunnerFailureSignatures = runnerFailureSignatures296
this.probeTimeoutMs = config.probeTimeoutMs as number297
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)298
// An operator-supplied runner does not use the ACL backend. The registry299
// 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 server305
// shutdown leaves no temp ACEs behind (workspace ACEs stand by design —306
// the reuse cache; an unclean shutdown leaves them for the next307
// provision's exact-ACE skip).308
ctx.effect(() => () => {309
this.revokeAclGrants()310
})311
}313
/**314
* Wrap `argv` in the selected runner's invocation for `policy` — the configured315
* `runnerCommand` when present (the operator's assertion, no probe), else the platform316
* 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, denial322
* signatures, and structured runner-failure rules; throws the fail-closed323
* `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
}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
}357
/**358
* The windows-acl runner argv for one policy. With a calling session (the359
* policy's `sessionId`) under workspace-write, the grants are materialized360
* once per provider lifetime — the standing workspace-root grant per361
* workspace and a revocable, RANDOM private-temp capability per live362
* session/workspace pair. The runner receives `--write-sid` plus363
* `--temp-write-sid` and grants nothing itself. Agentless workspace-write364
* calls pass the ambient temp ROOT and no SID flags: the runner creates and365
* 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.sessionId371
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
}390
/**391
* Materialize one workspace-write policy's ACEs once per provider392
* lifetime. The workspace SID and standing root grant are shared by the393
* workspace. The temp directory is random and carries a distinct SID, so394
* another session on the same workspace cannot use the shared workspace395
* SID to enter it. A fresh provider always chooses a new path; crash396
* residue therefore cannot collide with or authorize a resumed session.397
* Fail-closed: a half-materialized temp grant is revoked and its directory398
* 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 a412
// post-apply throw) is the intended end state, not an error413
// 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 error420
}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 existing426
const tempDir = mkdtempSync(join(tmpdir(), 'dsh-'))427
const tempSid = tempWriteSid(tempDir)428
let grant: AclWriteGrant | undefined429
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 error450
}451
const capability = { dir: tempDir, writeSid: tempSid, grant }452
this.tempCapabilities.set(key, capability)453
return capability454
}456
/**457
* Dispose every write grant (provider dispose): the revocable temp ACEs458
* are revoked, the private temp directories this provider created are459
* removed, and every SID allocation is freed; the standing workspace ACEs460
* stay (the reuse cache). Cleanup failures are reported, not thrown:461
* cordis teardown must not be aborted by grant cleanup. A crash skips all462
* 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) return467
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
}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
}496
/**497
* Resolve which runner confines commands, once, for the provider's498
* lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole499
* candidate selected directly, multiple candidates arbitrated by500
* functional probes in chain order. Fail closed when the platform has no501
* 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.selectedRunner507
}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] = chain513
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
}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 govern526
// every promised file effect by construction, so their passing probes527
// are always full enforcement; the Landlock launcher's probe report528
// distinguishes full from per-ABI-partial, while windows-acl is always529
// partial for its documented hard-link, unconfined-read, and530
// 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.probeWindowsAcl546
?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs))547
return probe() ? 'partial' : 'unusable'548
}549
default: return assertNever(runner)550
}551
}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
}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
}563
/**564
* The windows-acl runner argv prefix: the built lib/runner.js entry when565
* present (production), else the package source through tsx (development).566
* Pin the source loader and TypeScript paths to this installation, independently567
* of target cwd or environment overrides.568
* The prefix stays `[node, runner, ...]` — a future native-exe runner keeps569
* the same argv contract and only swaps these entries.570
*/571
private windowsAclRunnerInvocation(): string[] {572
const override = this.internals.windowsAclRunnerArgs573
if (override !== undefined) return override574
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
}583
export default LocalSandboxProvider