Merge remote-tracking branch 'origin/master' into codex/pr-555-ci-fix
# Conflicts: # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sandbox-local",
|
||||
"description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, or macOS Seatbelt — functionally probed, fail-closed",
|
||||
"description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -28,9 +28,11 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^",
|
||||
"@deepseek-ai/node-addon-landlock-run": "workspace:*",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
@@ -38,6 +40,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
/**
|
||||
* Local sandbox backend. It selects the platform runner chain (Linux bwrap then
|
||||
* Landlock; macOS Seatbelt), functionally probes competing candidates once, and
|
||||
* reports each wrap's enforcement and stderr classification facts. Missing or unusable
|
||||
* confinement fails closed rather than returning the original argv.
|
||||
* Landlock; macOS Seatbelt; Windows the ACL restricted-token runner), functionally probes
|
||||
* competing candidates once, and reports each wrap's enforcement and stderr
|
||||
* classification facts. Missing or unusable confinement fails closed rather
|
||||
* than returning the original argv.
|
||||
*
|
||||
* The windows-acl rung additionally owns the write grants: the write SID is
|
||||
* the per-WORKSPACE identity derived from the canonical workspace path
|
||||
* (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per
|
||||
* session (session id + workspace — nothing stored). The
|
||||
* workspace-root ACE materializes once per workspace per server lifetime
|
||||
* and STANDS (the cross-session reuse cache — the exact-ACE skip makes
|
||||
* every later provision O(1) instead of re-propagating the tree per
|
||||
* session); the private-temp ACEs are revoked on dispose. The runner
|
||||
* receives `--write-sid` (the derived identity; its presence marks the
|
||||
* seam-managed contract) and stops managing DACLs itself.
|
||||
* @module @deepseek-ai/dsh-sandbox-local
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
LAUNCHER_BIN,
|
||||
LAUNCHER_FAILURE_EXIT,
|
||||
@@ -18,6 +35,8 @@ import z from 'schemastery'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
@@ -70,6 +89,46 @@ function defaultProbeSeatbelt(seatbeltExec: string, timeoutMs: number): boolean
|
||||
return probe.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Functional windows-acl probe: run the runner in read-only mode (zero grants,
|
||||
* no ACL mutation) around `cmd /c exit 0` — exit 0 means the runner created
|
||||
* the restricted token and spawned the child under it. The win32 chain is a
|
||||
* sole candidate, so the product never probes; the probe exists for override
|
||||
* chains and mirrors the other rungs' shape.
|
||||
*/
|
||||
function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): boolean {
|
||||
const program = runnerInvocation[0]
|
||||
if (program === undefined) return false
|
||||
const probe = spawnSync(program, [
|
||||
...runnerInvocation.slice(1),
|
||||
'--workspace', tmpdir(), '--temp', tmpdir(), '--mode', 'read-only',
|
||||
'--', 'cmd', '/c', 'exit', '0',
|
||||
], {
|
||||
timeout: timeoutMs,
|
||||
stdio: 'ignore',
|
||||
})
|
||||
return probe.status === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's private temp subdirectory: `<tmpdir>\dsh-<16 hex>`, derived
|
||||
* from the session id and its workspace instead of stored. The same session
|
||||
* and workspace always name the same directory — a resumed session
|
||||
* re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's
|
||||
* different session id names a fresh one. The name is predictable to anyone
|
||||
* who knows the session id (the confined command sees it as
|
||||
* `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and
|
||||
* rejects reparse points: a pre-placed entry fails the first confined run
|
||||
* loudly, and cannot redirect the grant onto a foreign object.
|
||||
* @param sessionId - the policy's calling-session identity.
|
||||
* @param workspaceRoot - the resolved policy root.
|
||||
* @returns the session's private temp subdirectory path.
|
||||
*/
|
||||
export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string {
|
||||
const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex')
|
||||
return join(tmpdir(), `dsh-${digest.slice(0, 16)}`)
|
||||
}
|
||||
|
||||
/** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */
|
||||
export interface SandboxInternals {
|
||||
/** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */
|
||||
@@ -86,10 +145,18 @@ export interface SandboxInternals {
|
||||
landlockLauncher?: string
|
||||
/** Replaces the `sandbox-exec` executable the probe and wraps invoke (a fake script). */
|
||||
seatbeltExec?: string
|
||||
/** Replaces the resolved windows-acl runner argv prefix (a fake runner). */
|
||||
windowsAclRunnerArgs?: string[]
|
||||
/** Replaces the resolved windows-acl runner built entry path (a fake lib/runner.js location). */
|
||||
windowsAclRunnerEntry?: string
|
||||
/** Replaces the functional windows-acl probe (the win32 chain's sole rung — only consulted if that chain ever grows). */
|
||||
probeWindowsAcl?: () => boolean
|
||||
/** Replaces the private-temp-directory removal at provider dispose (a throwing fake exercises the cleanup-failure path). */
|
||||
rmTempDir?: (path: string) => void
|
||||
}
|
||||
|
||||
/** The chain's verdict: which runner confines, and how completely it enforces. */
|
||||
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement: SandboxEnforcement }
|
||||
type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement }
|
||||
|
||||
/**
|
||||
* The runner chain per platform — selection is BY PLATFORM first, probes
|
||||
@@ -103,11 +170,10 @@ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt'; enforcement:
|
||||
const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {
|
||||
linux: ['bwrap', 'landlock'],
|
||||
darwin: ['seatbelt'],
|
||||
// Reserved slot, deliberately empty: Windows support fills it with a confinement runner
|
||||
// (AppContainer / restricted-token family, shipped from its own repository on the
|
||||
// landlock-run template) plus a SelectedRunner['runner'] union member — the switches'
|
||||
// assertNever guards then walk the implementer to every site.
|
||||
win32: [],
|
||||
// The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl):
|
||||
// a sole candidate, selected without a probe — its execution-time refusal
|
||||
// fails closed through its stderr signature (windows-acl-run:) and exit 127.
|
||||
win32: ['windows-acl'],
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +189,13 @@ const STATIC_ENFORCEMENT: Record<SelectedRunner['runner'], SandboxEnforcement> =
|
||||
bwrap: 'full',
|
||||
landlock: 'full',
|
||||
seatbelt: 'full',
|
||||
// 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists
|
||||
// close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are
|
||||
// absent from both — pinned by the runner's Public-probe and CIM-denial
|
||||
// regressions). FAT-class (non-ACL) targets are declared unsupported
|
||||
// (warn-only) in the backend README — outside the promise, not an
|
||||
// exception to it.
|
||||
'windows-acl': 'full',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,15 +218,26 @@ const DENIAL_SIGNATURES = {
|
||||
bwrap: ['read-only file system'],
|
||||
landlock: ['permission denied'],
|
||||
seatbelt: ['operation not permitted'],
|
||||
// pwsh/.NET: "Access to the path '...' is denied."; cmd: "Access is denied.";
|
||||
// node EACCES: "permission denied".
|
||||
'windows-acl': ['access is denied', 'access to the path', 'permission denied'],
|
||||
runnerCommand: ['read-only file system', 'permission denied'],
|
||||
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
|
||||
|
||||
/** The windows-acl runner's documented failure exit (its own RUNNER_FAILURE_EXIT contract, distinct from Landlock's 125). */
|
||||
const WINDOWS_ACL_RUNNER_FAILURE_EXIT = 127
|
||||
|
||||
/**
|
||||
* Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus
|
||||
* fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit
|
||||
* 1 but its public contract does not reserve that status, while sandbox-exec
|
||||
* publishes no launcher-failure status; those backends remain signature-only.
|
||||
* Keep the Landlock tuple aligned with the assembled snapshot fixture at
|
||||
* The windows-acl runner prints `windows-acl-run: <detail>` on every
|
||||
* runner-side failure and exits 127 — the rule is exit-gated on that status
|
||||
* so a confined command that merely PRINTS the signature (or a runner
|
||||
* cleanup failure reported on a non-zero child exit) is never misclassified
|
||||
* as "the command did not run". Keep the Landlock tuple aligned with the
|
||||
* assembled snapshot fixture at
|
||||
* `examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts`.
|
||||
*/
|
||||
const RUNNER_FAILURE_RULES = {
|
||||
@@ -164,12 +248,15 @@ const RUNNER_FAILURE_RULES = {
|
||||
informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`],
|
||||
}],
|
||||
seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }],
|
||||
'windows-acl': [{ allowedExitCodes: [WINDOWS_ACL_RUNNER_FAILURE_EXIT], fatalSignatures: ['windows-acl-run: '] }],
|
||||
} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]>
|
||||
|
||||
/**
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless
|
||||
* apart from the cached chain verdict — it spawns nothing but the one-time
|
||||
* probes, so there is no disposal work beyond cordis' own.
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
|
||||
* chain verdict and, on the windows-acl rung, the write grants
|
||||
* ({@link AclWriteGrant}: the standing workspace-root grant per workspace
|
||||
* and the revocable private-temp grant per session, the latter revoked on
|
||||
* provider dispose); the one-time probes spawn nothing else.
|
||||
*/
|
||||
export class LocalSandboxProvider extends SandboxProvider {
|
||||
// Inline schema call: the config catalog walks `static Config` statically.
|
||||
@@ -187,6 +274,16 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
private readonly probeTimeoutMs: number
|
||||
/** Cached chain verdict; undefined until the first confined wrap needs it. */
|
||||
private selectedRunner: SelectedRunner | 'unavailable' | undefined
|
||||
/**
|
||||
* Server-lifetime write grants (windows-acl rung): the STANDING
|
||||
* workspace-root grant per workspace (its ACE is the cross-session reuse
|
||||
* cache and outlives the provider — never revoked) and the REVOCABLE
|
||||
* private-temp grant per session (revoked on provider dispose).
|
||||
*/
|
||||
private readonly workspaceGrants = new Map<string, AclWriteGrant>()
|
||||
private readonly tempGrants = new Map<string, AclWriteGrant>()
|
||||
/** Session id → the private temp directory this provider created (removed on dispose). */
|
||||
private readonly tempDirs = new Map<string, string>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
@@ -208,6 +305,13 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
this.configuredRunnerFailureSignatures = runnerFailureSignatures
|
||||
this.probeTimeoutMs = config.probeTimeoutMs as number
|
||||
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
|
||||
// The temp grants are revoked with the provider: a clean server
|
||||
// shutdown leaves no temp ACEs behind (workspace ACEs stand by design —
|
||||
// the reuse cache; an unclean shutdown leaves them for the next
|
||||
// provision's exact-ACE skip).
|
||||
ctx.effect(() => () => {
|
||||
this.revokeAclGrants()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,10 +350,154 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
case 'bwrap': return ['bwrap', ...bwrapProfileArgs(policy)]
|
||||
case 'landlock': return [this.landlockLauncher(), ...landlockProfileArgs(policy)]
|
||||
case 'seatbelt': return [this.seatbeltExec(), ...seatbeltProfileArgs(policy)]
|
||||
case 'windows-acl': return this.windowsAclRunnerArgv(policy)
|
||||
default: return assertNever(runner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The windows-acl runner argv for one policy. With a calling session (the
|
||||
* policy's `sessionId`), the write grants are materialized once per server
|
||||
* lifetime — the standing workspace-root grant per workspace and the
|
||||
* revocable private-temp grant per session — and the runner receives
|
||||
* `--write-sid` (the workspace-derived identity; its presence marks the
|
||||
* seam-managed DACL contract) plus, under workspace-write, the session's
|
||||
* PRIVATE temp subdirectory (derived from session id + workspace) — it
|
||||
* grants nothing and revokes nothing. Agentless calls pass the ambient
|
||||
* temp root and no `--write-sid`: the runner self-manages its DACLs.
|
||||
* @param policy - the resolved per-call policy.
|
||||
* @returns the runner invocation.
|
||||
*/
|
||||
private windowsAclRunnerArgv(policy: SandboxPolicy): string[] {
|
||||
const sessionId = policy.sessionId
|
||||
if (sessionId === undefined) {
|
||||
return [
|
||||
...this.windowsAclRunnerInvocation(),
|
||||
'--workspace', policy.workspaceRoot,
|
||||
'--temp', tmpdir(),
|
||||
'--mode', policy.mode,
|
||||
]
|
||||
}
|
||||
this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode)
|
||||
return [
|
||||
...this.windowsAclRunnerInvocation(),
|
||||
'--workspace', policy.workspaceRoot,
|
||||
// Workspace-write sessions confine their temp writes to the PRIVATE
|
||||
// per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only
|
||||
// runs pass the ambient temp root — the runner validates it exists
|
||||
// but grants nothing. The derived write SID is the per-workspace
|
||||
// identity; the flag's presence marks the seam-managed DACL contract.
|
||||
'--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(),
|
||||
'--mode', policy.mode,
|
||||
'--write-sid', workspaceWriteSid(policy.workspaceRoot),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the session's ACEs once per server lifetime: lazily at its
|
||||
* first confined execution, reused for every later call (the map hits are
|
||||
* the whole call). The write SID is the per-workspace identity derived
|
||||
* from the workspace. Workspace-write grants the workspace root STANDING
|
||||
* (the ACE outlives every session — the reuse cache) and the session's
|
||||
* private temp subdirectory REVOCABLY — the directory is derived from
|
||||
* session id + workspace, created here EXCLUSIVELY (a pre-existing entry
|
||||
* or a reparse point fails the first confined run loudly, so the grant
|
||||
* never lands on a foreign object); read-only materializes NOTHING — its
|
||||
* token alone restricts every write, and the standing grant from an
|
||||
* earlier workspace-write period is KEPT through a downgrade (never
|
||||
* revoked): the read-only restricted token carries no write SID (the
|
||||
* read-only list), so the ACE is inert there, while the map hit keeps the
|
||||
* re-upgrade free of re-propagation. Fail-closed: a half-materialized
|
||||
* temp grant is revoked before the error propagates.
|
||||
* @param sessionId - the policy's calling-session identity.
|
||||
* @param workspaceRoot - the resolved policy root.
|
||||
* @param mode - the policy mode (grants exist only under workspace-write).
|
||||
*/
|
||||
private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void {
|
||||
if (mode === 'read-only') return
|
||||
const writeSid = workspaceWriteSid(workspaceRoot)
|
||||
const tempDir = sessionTempDir(sessionId, workspaceRoot)
|
||||
if (!this.workspaceGrants.has(workspaceRoot)) {
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
try {
|
||||
grant.add(workspaceRoot, true)
|
||||
} catch (error) {
|
||||
// Free the SID; a standing ACE (if the apply succeeded before a
|
||||
// post-apply throw) is the intended end state, not an error
|
||||
// artifact — nothing to revoke.
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl workspace grant failed and its cleanup also failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.workspaceGrants.set(workspaceRoot, grant)
|
||||
}
|
||||
if (this.tempGrants.has(sessionId)) return
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
// The directory is removed again in the catch only when THIS confine
|
||||
// created it — a pre-existing entry (EEXIST) is a foreign object and is
|
||||
// never deleted.
|
||||
let created = false
|
||||
try {
|
||||
// Exclusive creation (no `recursive`): a pre-existing entry OR a
|
||||
// reparse point both fail EEXIST — the grant never lands on a foreign
|
||||
// object.
|
||||
mkdirSync(tempDir)
|
||||
created = true
|
||||
grant.add(tempDir)
|
||||
} catch (error) {
|
||||
if (created) rmSync(tempDir, { recursive: true, force: true })
|
||||
// Revoke whatever stands and free the SID — never leave a half-grant
|
||||
// behind a failed confine (the runner never runs).
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.tempGrants.set(sessionId, grant)
|
||||
this.tempDirs.set(sessionId, tempDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose every write grant (provider dispose): the revocable temp ACEs
|
||||
* are revoked, the private temp directories this provider created are
|
||||
* removed, and every SID allocation is freed; the standing workspace ACEs
|
||||
* stay (the reuse cache). Cleanup failures are reported, not thrown:
|
||||
* cordis teardown must not be aborted by grant cleanup. A crash skips all
|
||||
* of it — the next resume then fails loudly at the exclusive creation and
|
||||
* OS temp hygiene (or manual removal) recovers.
|
||||
*/
|
||||
private revokeAclGrants(): void {
|
||||
if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return
|
||||
const failures: unknown[] = []
|
||||
for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) {
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) })
|
||||
for (const dir of this.tempDirs.values()) {
|
||||
try {
|
||||
rmTempDir(dir)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
this.workspaceGrants.clear()
|
||||
this.tempGrants.clear()
|
||||
this.tempDirs.clear()
|
||||
if (failures.length > 0) {
|
||||
this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`)
|
||||
for (const error of failures) this.ctx.logger.warn(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which runner confines commands, once, for the provider's
|
||||
* lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole
|
||||
@@ -296,6 +544,11 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
const probe = this.internals.probeSeatbelt ?? (exec => defaultProbeSeatbelt(exec, this.probeTimeoutMs))
|
||||
return probe(this.seatbeltExec()) ? 'full' : 'unusable'
|
||||
}
|
||||
case 'windows-acl': {
|
||||
const probe = this.internals.probeWindowsAcl
|
||||
?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs))
|
||||
return probe() ? 'full' : 'unusable'
|
||||
}
|
||||
default: return assertNever(runner)
|
||||
}
|
||||
}
|
||||
@@ -309,6 +562,21 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
private seatbeltExec(): string {
|
||||
return this.internals.seatbeltExec ?? 'sandbox-exec'
|
||||
}
|
||||
|
||||
/**
|
||||
* The windows-acl runner argv prefix: the built lib/runner.js entry when
|
||||
* present (production), else the package source through tsx (development).
|
||||
* The prefix stays `[node, runner, ...]` — a future native-exe runner keeps
|
||||
* the same argv contract and only swaps these entries.
|
||||
*/
|
||||
private windowsAclRunnerInvocation(): string[] {
|
||||
const override = this.internals.windowsAclRunnerArgs
|
||||
if (override !== undefined) return override
|
||||
const builtEntry = this.internals.windowsAclRunnerEntry ?? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner'))
|
||||
if (existsSync(builtEntry)) return [process.execPath, builtEntry]
|
||||
const sourceEntry = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/src/runner.ts'))
|
||||
return [process.execPath, '--import', 'tsx/esm', sourceEntry]
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalSandboxProvider
|
||||
|
||||
404
packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
Normal file
404
packages/sandbox/sandbox-local/tests/acl-grants.spec.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* windows-acl write grants: the SERVER-LIFETIME ACE materialization
|
||||
* (standing workspace grant per workspace, revocable private-temp grant per
|
||||
* session) plus the derived private-temp identity, through the REAL
|
||||
* LocalSandboxProvider.confine(). Win32 surface mocked at the package
|
||||
* boundary (the workspace-derived SID mocked to a constant); the real-FFI
|
||||
* grant behavior lives in sandbox-windows-acl's win32 tests.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
|
||||
const mockState = vi.hoisted(() => ({
|
||||
grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>,
|
||||
addFailure: undefined as Error | undefined,
|
||||
/** Restricts {@link addFailure} to this path (undefined = every add throws). */
|
||||
addFailurePath: undefined as string | undefined,
|
||||
disposeFailure: undefined as Error | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
|
||||
class MockAclWriteGrant {
|
||||
readonly writeSid: string
|
||||
readonly added: Array<{ path: string; standing: boolean }> = []
|
||||
disposed = false
|
||||
constructor(writeSid: string) {
|
||||
this.writeSid = writeSid
|
||||
mockState.grants.push(this)
|
||||
}
|
||||
static create(writeSid: string): MockAclWriteGrant {
|
||||
return new MockAclWriteGrant(writeSid)
|
||||
}
|
||||
add(path: string, standing = false): void {
|
||||
if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) {
|
||||
throw mockState.addFailure
|
||||
}
|
||||
this.added.push({ path, standing })
|
||||
}
|
||||
dispose(): void {
|
||||
if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure
|
||||
this.disposed = true
|
||||
}
|
||||
}
|
||||
return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' }
|
||||
})
|
||||
|
||||
/** The workspace-derived write SID the mock pins for every workspace. */
|
||||
const DERIVED_SID = 'S-1-4-42-42'
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = { platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }
|
||||
return { ctx, sandbox, fiber }
|
||||
}
|
||||
|
||||
/** A workspace root the policy carries. */
|
||||
function workspaceRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-'))
|
||||
}
|
||||
|
||||
describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
const scratch: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
mockState.grants = []
|
||||
mockState.addFailure = undefined
|
||||
mockState.addFailurePath = undefined
|
||||
mockState.disposeFailure = undefined
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => {
|
||||
try {
|
||||
const { sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-1'), ws)
|
||||
scratch.push(tempDir)
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') }
|
||||
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked
|
||||
disposed: false,
|
||||
})
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: tempDir, standing: false }],
|
||||
disposed: false,
|
||||
})
|
||||
expect(existsSync(tempDir)).toBe(true) // created exclusively
|
||||
|
||||
// Reuse: the second confine is the map hits.
|
||||
sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
await fiber.dispose()
|
||||
// dispose() runs on BOTH grants: the standing workspace ACE is left in
|
||||
// place (the mock marks it disposed only as instance teardown).
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
expect(mockState.grants[1]!.disposed).toBe(true)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-switch'), ws)
|
||||
scratch.push(tempDir)
|
||||
const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
|
||||
const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') }
|
||||
|
||||
// read-only first: nothing materialized, ambient temp.
|
||||
const confinedRo = sandbox.confine(['true'], readOnly)
|
||||
expect(confinedRo.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tmpdir(), // NOT the private subdir: read-only grants nothing
|
||||
'--mode', 'read-only',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
expect(existsSync(tempDir)).toBe(false)
|
||||
|
||||
// Upgrade: first workspace-write materializes with the derived SID.
|
||||
const upgraded = sandbox.confine(['true'], workspaceWrite)
|
||||
expect(upgraded.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', ws,
|
||||
'--temp', tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false })
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: tempDir, standing: false }],
|
||||
disposed: false,
|
||||
})
|
||||
expect(existsSync(tempDir)).toBe(true)
|
||||
|
||||
// Reuse: map hits.
|
||||
sandbox.confine(['true'], workspaceWrite)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
// Downgrade: standing grant KEPT (inert under read-only, free re-upgrade).
|
||||
sandbox.confine(['true'], readOnly)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => {
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const first = await setup()
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') }
|
||||
const firstConfined = first.sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
// Clean restart: dispose revokes the temp ACE and removes the private
|
||||
// temp directory, so the fresh provider's exclusive creation succeeds.
|
||||
await first.fiber.dispose()
|
||||
mockState.grants = []
|
||||
const second = await setup()
|
||||
const secondConfined = second.sandbox.confine(['true'], policy)
|
||||
expect(secondConfined.argv).toEqual(firstConfined.argv)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[1]).toMatchObject({
|
||||
writeSid: DERIVED_SID,
|
||||
added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }],
|
||||
})
|
||||
await second.fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') }
|
||||
const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') }
|
||||
|
||||
sandbox.confine(['true'], parentPolicy)
|
||||
const parentTemp = sessionTempDir(SessionId('parent'), ws)
|
||||
scratch.push(parentTemp)
|
||||
sandbox.confine(['true'], childPolicy)
|
||||
const childTemp = sessionTempDir(SessionId('child'), ws)
|
||||
scratch.push(childTemp)
|
||||
|
||||
// Fresh temp identity, NOT the parent's (the workspace SID is shared by
|
||||
// derivation — the workspace is the same, so the standing grant is the
|
||||
// map hit and only the child's temp grant joins).
|
||||
expect(childTemp).not.toBe(parentTemp)
|
||||
expect(mockState.grants).toHaveLength(3)
|
||||
expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] })
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
|
||||
// Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it.
|
||||
const preexisting = sessionTempDir(SessionId('preexisting'), ws)
|
||||
mkdirSync(preexisting)
|
||||
scratch.push(preexisting)
|
||||
const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') }
|
||||
expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/)
|
||||
// The standing workspace grant is the intended end state and stays; the
|
||||
// failed temp grant self-disposes.
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked
|
||||
|
||||
// Reparse point: same EEXIST (exclusive mkdir never follows links).
|
||||
const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-'))
|
||||
scratch.push(target)
|
||||
const linkPath = sessionTempDir(SessionId('reparse'), ws)
|
||||
symlinkSync(target, linkPath)
|
||||
scratch.push(linkPath)
|
||||
const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') }
|
||||
expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/)
|
||||
// Same workspace as the preexisting case: the standing workspace grant
|
||||
// is the map hit (not recreated) — only the failed temp grant joins.
|
||||
expect(mockState.grants).toHaveLength(3)
|
||||
expect(mockState.grants[2]!.disposed).toBe(true)
|
||||
|
||||
// Temp-side cleanup failure: the standing workspace grant stays (map
|
||||
// hit), the exclusive mkdir fails, AND the temp grant's dispose also
|
||||
// fails — the temp cleanup AggregateError propagates.
|
||||
mockState.grants = []
|
||||
mockState.disposeFailure = new Error('temp cleanup exploded')
|
||||
const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws)
|
||||
mkdirSync(dupTemp)
|
||||
scratch.push(dupTemp)
|
||||
const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') }
|
||||
expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/)
|
||||
expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') }
|
||||
|
||||
// add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates.
|
||||
mockState.addFailure = new Error('grant exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded')
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(true)
|
||||
|
||||
// add() AND dispose() both throw: AggregateError.
|
||||
mockState.grants = []
|
||||
mockState.addFailure = new Error('grant exploded again')
|
||||
mockState.disposeFailure = new Error('cleanup exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => {
|
||||
try {
|
||||
const { sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws)
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') }
|
||||
|
||||
// The workspace grant succeeds; only the TEMP grant's add throws (the
|
||||
// path-targeted failure keeps the workspace branch intact).
|
||||
mockState.addFailurePath = tempDir
|
||||
mockState.addFailure = new Error('temp add exploded')
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded')
|
||||
expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays
|
||||
expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => {
|
||||
try {
|
||||
const { sandbox, fiber } = await setup()
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', '/ws',
|
||||
'--temp', tmpdir(),
|
||||
'--mode', 'workspace-write',
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(0)
|
||||
await fiber.dispose()
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => {
|
||||
try {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-dispose'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') }
|
||||
sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
mockState.disposeFailure = new Error('revoke exploded')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
await fiber.dispose()
|
||||
// BOTH grants (standing workspace + revocable temp) fail their dispose.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' }))
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => {
|
||||
try {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws))
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') }
|
||||
sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') }
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
await fiber.dispose()
|
||||
// Both grants dispose cleanly; only the directory removal fails.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' }))
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => {
|
||||
const base = sessionTempDir(SessionId('sess-a'), '/ws/a')
|
||||
expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/)
|
||||
expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base)
|
||||
expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session
|
||||
expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace
|
||||
// The separator prevents id/workspace collisions from merging inputs.
|
||||
expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc'))
|
||||
})
|
||||
})
|
||||
@@ -209,13 +209,10 @@ describe('the platform chains', () => {
|
||||
expect(probeSeatbelt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('win32 is a reserved EMPTY chain: fails closed identically until a Windows runner fills it', async () => {
|
||||
// The slot exists so Windows support is an additive fill-in (chain entry
|
||||
// + runner union member), never a redesign — and reserving it must not
|
||||
// weaken the fail-closed end in the meantime.
|
||||
const { sandbox } = await setup({}, { platform: 'win32' })
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
// The win32 chain's argv contract, denial dialect, and runner-failure rules
|
||||
// live in @deepseek-ai/dsh-sandbox-windows-acl/tests/provider-chain.spec.ts
|
||||
// (platform-independent assertions that run in every CI lane, including
|
||||
// Windows where this package's POSIX-only suites are excluded).
|
||||
|
||||
it('caches the verdict for the provider lifetime: one chain walk across wraps', async () => {
|
||||
const probeBwrap = vi.fn(() => true)
|
||||
@@ -368,3 +365,63 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
|
||||
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('the windows-acl probe (runner invocation contract)', () => {
|
||||
// The product chain reaches windows-acl only unprobed (win32's sole
|
||||
// candidate), so the probe case and the runner-entry resolution are pinned
|
||||
// through the chain seam, mirroring the seatbelt default-probe contract.
|
||||
it('selects the rung when the injected probe passes, speaking the ACL dialect', async () => {
|
||||
const probeWindowsAcl = vi.fn(() => true)
|
||||
const { sandbox } = await setup({}, {
|
||||
chain: ['windows-acl', 'bwrap'],
|
||||
probeWindowsAcl,
|
||||
probeBwrap: () => false,
|
||||
windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'],
|
||||
})
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(probeWindowsAcl).toHaveBeenCalledTimes(1)
|
||||
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied'])
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
})
|
||||
|
||||
it('reads a failing probe as unusable and walks to the next rung', async () => {
|
||||
const probeWindowsAcl = vi.fn(() => false)
|
||||
const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeWindowsAcl, probeBwrap: () => true })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv[0]).toBe('bwrap')
|
||||
expect(probeWindowsAcl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('runs the REAL default probe against the resolved runner invocation when none is injected', async () => {
|
||||
// The default probe spawns the exact runner argv confine would use — the
|
||||
// runner source through tsx on a lib-less checkout. The windows-acl
|
||||
// runner cannot init off win32, so the probe reads unusable and the walk
|
||||
// falls through to the injected bwrap verdict on every host.
|
||||
const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv[0]).toBe('bwrap')
|
||||
}, 30_000)
|
||||
|
||||
it('reads an empty runner invocation as unusable (the probe\'s empty-argv guard)', async () => {
|
||||
// windowsAclRunnerInvocation always yields [node, ...] in product; an
|
||||
// override returning [] exercises the default probe's empty-argv guard.
|
||||
const { sandbox } = await setup({}, { chain: ['windows-acl', 'bwrap'], probeBwrap: () => true, windowsAclRunnerArgs: [] })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv[0]).toBe('bwrap')
|
||||
})
|
||||
|
||||
it('prefers the built lib/runner.js entry when the resolved file exists', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-'))
|
||||
const builtEntry = join(dir, 'runner.js')
|
||||
writeFileSync(builtEntry, '')
|
||||
const { sandbox } = await setup({}, {
|
||||
chain: ['windows-acl', 'bwrap'],
|
||||
probeWindowsAcl: () => true,
|
||||
windowsAclRunnerEntry: builtEntry,
|
||||
})
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv.slice(0, 2)).toEqual([process.execPath, builtEntry])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,10 @@ const platformPackageName = `@deepseek-ai/node-addon-landlock-run-linux-${proces
|
||||
/** The harness closure the consumer needs; native tarballs are packed through their mode-preserving release script. */
|
||||
const WORKSPACE_CLOSURE = [
|
||||
'packages/sandbox/sandbox-local',
|
||||
// sandbox-local's win32 chain rung is a runtime dependency: a packed
|
||||
// consumer resolves it like any other @deepseek-ai peer (koffi arrives
|
||||
// from the registry).
|
||||
'packages/sandbox/sandbox-windows-acl',
|
||||
'packages/sandbox/sandbox',
|
||||
'packages/llm/llm',
|
||||
'packages/attachment/attachment',
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{
|
||||
"path": "../sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../sandbox-windows-acl"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user