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
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ export class SandboxPolicyService extends Service {
|
||||
return {
|
||||
mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,
|
||||
workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),
|
||||
...session === undefined ? {} : { sessionId: session.id },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,10 +69,12 @@ describe('SandboxPolicyService', () => {
|
||||
expect(ctx.sandboxPolicy.resolve({ session: first })).toEqual({
|
||||
mode: 'workspace-write',
|
||||
workspaceRoot: resolve('/projects/first'),
|
||||
sessionId: 'sess-first',
|
||||
})
|
||||
expect(ctx.sandboxPolicy.resolve({ session: second })).toEqual({
|
||||
mode: 'read-only',
|
||||
workspaceRoot: resolve('/projects/second'),
|
||||
sessionId: 'sess-second',
|
||||
})
|
||||
expect(ctx.sandboxPolicy.overrideOf(first)).toBeUndefined()
|
||||
expect(ctx.sandboxPolicy.overrideOf(second)).toBe('read-only')
|
||||
@@ -98,6 +100,7 @@ describe('SandboxPolicyService', () => {
|
||||
expect(ctx.sandboxPolicy.resolve({ session: session('sess-symlink-parent', cwd) })).toEqual({
|
||||
mode: 'workspace-write',
|
||||
workspaceRoot: realpathSync.native(physical),
|
||||
sessionId: 'sess-symlink-parent',
|
||||
})
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
@@ -111,6 +114,7 @@ describe('SandboxPolicyService', () => {
|
||||
expect(ctx.sandboxPolicy.resolve({ session: active, mode: 'danger-full-access' })).toEqual({
|
||||
mode: 'danger-full-access',
|
||||
workspaceRoot: resolve('/projects/approved'),
|
||||
sessionId: 'sess-approved',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md
|
||||
README.md: b13160f7490878143c719ca617936b74ffd298af
|
||||
README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44
|
||||
@@ -0,0 +1,91 @@
|
||||
# @deepseek-ai/dsh-sandbox-windows-acl
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends.
|
||||
|
||||
Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary).
|
||||
|
||||
Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all).
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
|
||||
const workspaceRoot = process.cwd()
|
||||
|
||||
// mode selects the token's restricting-SID list (see Modes below) and must
|
||||
// match the grant shape: read-only pairs with zero grants. workspace-write
|
||||
// REQUIRES the workspace's write SID — the per-workspace identity.
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' })
|
||||
await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted
|
||||
|
||||
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
||||
const { stdout, stderr, exitCode } = await child.wait()
|
||||
|
||||
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure
|
||||
```
|
||||
|
||||
A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction.
|
||||
|
||||
## The confinement runner
|
||||
|
||||
The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract:
|
||||
|
||||
```sh
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...>
|
||||
```
|
||||
|
||||
The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: <detail>` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial.
|
||||
|
||||
**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host).
|
||||
|
||||
Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them):
|
||||
- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection.
|
||||
- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL).
|
||||
|
||||
Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note).
|
||||
|
||||
The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle.
|
||||
|
||||
## Header verification
|
||||
|
||||
All constants, signatures, and struct layouts were verified against the Windows headers on the development machine (MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`) and are cross-checked at runtime by [`verify/abi-probe.cpp`](verify/abi-probe.cpp) (sizes, offsets, enum values, static asserts):
|
||||
|
||||
```sh
|
||||
g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe
|
||||
```
|
||||
|
||||
The koffi struct definitions assert their sizes against the probe at module load, so a header/koffi layout drift fails loudly instead of corrupting memory.
|
||||
|
||||
## Verified boundaries (inherent to restricted tokens, not this port)
|
||||
|
||||
- **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement.
|
||||
- **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected.
|
||||
- **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls <dir> /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace.
|
||||
- **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation.
|
||||
- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`<temp>\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead.
|
||||
- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation.
|
||||
- **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None directly; the denial surface belongs to the tool layer.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path.
|
||||
- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove.
|
||||
- **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them.
|
||||
- **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path.
|
||||
- **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined.
|
||||
- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow.
|
||||
- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this.
|
||||
- **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement.
|
||||
- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated.
|
||||
- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage.
|
||||
@@ -0,0 +1,93 @@
|
||||
# @deepseek-ai/dsh-sandbox-windows-acl
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。
|
||||
|
||||
一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。
|
||||
|
||||
直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。
|
||||
|
||||
## 用法
|
||||
|
||||
```ts
|
||||
import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
|
||||
const workspaceRoot = process.cwd()
|
||||
|
||||
// mode selects the token's restricting-SID list (see Modes below) and must
|
||||
// match the grant shape: read-only pairs with zero grants. workspace-write
|
||||
// REQUIRES the workspace's write SID — the per-workspace identity.
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' })
|
||||
await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted
|
||||
|
||||
const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot })
|
||||
const { stdout, stderr, exitCode } = await child.wait()
|
||||
|
||||
sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure
|
||||
```
|
||||
|
||||
直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。
|
||||
|
||||
<a id="the-confinement-runner"></a>
|
||||
|
||||
## 隔离 runner
|
||||
|
||||
面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约:
|
||||
|
||||
```sh
|
||||
node runner.js --workspace <dir> --temp <dir> --mode <read-only|workspace-write> [--write-sid <S-1-4-…>] -- <argv...>
|
||||
```
|
||||
|
||||
runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: <detail>` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。
|
||||
|
||||
**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。
|
||||
|
||||
模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃):
|
||||
- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。
|
||||
- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。
|
||||
|
||||
Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。
|
||||
|
||||
`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。
|
||||
|
||||
## 头部验证
|
||||
|
||||
所有常量、签名与结构体布局都在开发机上对照 Windows 头文件(MinGW `winnt.h` / `accctrl.h` / `aclapi.h` / `securitybaseapi.h` / `sddl.h` / `processthreadsapi.h` / `fileapi.h` / `namedpipeapi.h` / `synchapi.h` / `winbase.h`)验证过,并在运行时由 [`verify/abi-probe.cpp`](verify/abi-probe.cpp)(大小、偏移、枚举值、静态断言)交叉检查:
|
||||
|
||||
```sh
|
||||
g++ -std=c++20 -municode -O2 -o abi-probe.exe verify/abi-probe.cpp -ladvapi32 && ./abi-probe.exe
|
||||
```
|
||||
|
||||
koffi 结构体定义在模块加载时对照探针断言其大小,因此头文件/koffi 布局漂移会大声失败而不是破坏内存。
|
||||
|
||||
## 已验证边界(受限令牌固有,非本移植引入)
|
||||
|
||||
- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。
|
||||
- **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。
|
||||
- **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls <dir> /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。
|
||||
- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。
|
||||
- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`<temp>\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。
|
||||
- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。
|
||||
- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。
|
||||
|
||||
## Model Experience
|
||||
|
||||
间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无直接影响;拒绝面属于工具层。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。
|
||||
- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。
|
||||
- **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。
|
||||
- **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。
|
||||
- **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。
|
||||
- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。
|
||||
- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。
|
||||
- **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。
|
||||
- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。
|
||||
- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sandbox-windows-acl",
|
||||
"description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./runner": {
|
||||
"types": "./lib/types/runner.d.ts",
|
||||
"default": "./lib/runner.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/runner.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* ACL editing helpers: grant/revoke the orphan write SID on a directory via
|
||||
* SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with
|
||||
* the failure handling the POC lacks). Every API call is checked and every
|
||||
* failure is reported with the API name, the exact Win32 code, the formatted
|
||||
* system text, and the affected path.
|
||||
*
|
||||
* Concurrency: grants are read-merge-write against the directory's CURRENT
|
||||
* DACL, and the whole get-merge-set sequence runs under a per-path exclusive
|
||||
* LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances
|
||||
* cannot clobber each other's ACEs.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/acl
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { allocOverlapped, allocPtrSlot, decodePtr, decodeUint8At, decodeUint16At, decodeUint32At, getTempPath, isInvalidHandle, isNullPtr, ptrAddress, sameSidAt, throwLastError, throwWin32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
/**
|
||||
* Pack one EXPLICIT_ACCESS_W (48 bytes, layout verified by abi-probe.cpp):
|
||||
* perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16,
|
||||
* MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }.
|
||||
* `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which
|
||||
* removes every ACE for the trustee.
|
||||
* @param sidPtr - the trustee SID the entry names.
|
||||
* @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS).
|
||||
* @param permissions - the access mask to grant (0 for REVOKE_ACCESS).
|
||||
* @returns the packed entry buffer.
|
||||
*/
|
||||
export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer {
|
||||
const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE)
|
||||
entry.writeUInt32LE(permissions, 0) // grfAccessPermissions
|
||||
entry.writeUInt32LE(mode, 4) // grfAccessMode
|
||||
entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI
|
||||
entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation
|
||||
entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm
|
||||
entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType
|
||||
entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* One lock file per protected path: `<GetTempPathW()>\dsh-acl-locks\<first 16
|
||||
* hex of sha256(lowercased path)>.lock`. The lock root derives from
|
||||
* GetTempPathW (never from runner argv or DSH_HOME), and the lowercasing
|
||||
* maps Windows's case-insensitive path spellings onto one lock.
|
||||
* @param api - the binding table.
|
||||
* @param path - the protected directory (absolute).
|
||||
* @returns the lock file path for that directory.
|
||||
*/
|
||||
export function lockFilePath(api: Win32Bindings, path: string): string {
|
||||
const digest = createHash('sha256').update(path.toLowerCase()).digest('hex').slice(0, 16)
|
||||
return join(getTempPath(api), 'dsh-acl-locks', `${digest}.lock`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `action` holding the per-path exclusive lock: CreateFileW
|
||||
* (OPEN_ALWAYS, shared read/write but NOT delete — a deletable lock file
|
||||
* could be removed and recreated under the holder, letting two processes
|
||||
* hold "the same" lock), then a one-byte LockFileEx
|
||||
* (LOCKFILE_EXCLUSIVE_LOCK, zeroed OVERLAPPED = lock from offset 0 on the
|
||||
* synchronous handle — see allocOverlapped for why not NULL), then
|
||||
* UnlockFileEx + CloseHandle. Fail-closed: open/lock/unlock/close failures
|
||||
* throw like every other Win32 call in this package; an `action` failure
|
||||
* still unlocks (best-effort) and rethrows the original error.
|
||||
* @param api - the binding table.
|
||||
* @param path - the protected directory (absolute).
|
||||
* @param action - the get-merge-set sequence to serialize.
|
||||
* @returns the action's result.
|
||||
*/
|
||||
export function withPathLock<T>(api: Win32Bindings, path: string, action: () => T): T {
|
||||
const lockPath = lockFilePath(api, path)
|
||||
mkdirSync(dirname(lockPath), { recursive: true })
|
||||
const handle = api.createFileW(
|
||||
lockPath,
|
||||
abi.GENERIC_READ | abi.GENERIC_WRITE,
|
||||
abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE,
|
||||
null, abi.OPEN_ALWAYS, 0, null,
|
||||
)
|
||||
if (isInvalidHandle(handle)) throwLastError(api, 'CreateFileW', lockPath)
|
||||
const overlapped = allocOverlapped() // stays zeroed: offset 0, hEvent NULL
|
||||
if (api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, overlapped) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(handle) // best-effort on the lock-failure path
|
||||
throwWin32(api, 'LockFileEx', win32Code, lockPath)
|
||||
}
|
||||
|
||||
let result: T
|
||||
try {
|
||||
result = action()
|
||||
} catch (error) {
|
||||
// Best-effort release on the action-failure path: cleanup failures must
|
||||
// not mask the action's error.
|
||||
api.unlockFileEx(handle, 0, 1, 0, overlapped)
|
||||
api.closeHandle(handle)
|
||||
throw error
|
||||
}
|
||||
if (api.unlockFileEx(handle, 0, 1, 0, overlapped) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(handle) // best-effort on the unlock-failure path
|
||||
throwWin32(api, 'UnlockFileEx', win32Code, lockPath)
|
||||
}
|
||||
if (api.closeHandle(handle) === 0) throwLastError(api, 'CloseHandle', `lock file ${lockPath}`)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the directory's current explicit DACL via GetNamedSecurityInfoW.
|
||||
* Allocation contract (the POC's RevokeAccess, minus its missing checks): the
|
||||
* returned ACL pointer sits INSIDE the security descriptor allocation — only
|
||||
* the descriptor may be LocalFree'd, and it must not be freed before
|
||||
* SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself
|
||||
* corrupts the heap (verified the hard way).
|
||||
* @param api - the binding table.
|
||||
* @param path - the directory whose DACL is read.
|
||||
* @returns the current explicit DACL (null when the directory carries none) and its owning descriptor.
|
||||
*/
|
||||
function readCurrentDacl(api: Win32Bindings, path: string): { oldAcl: NativePtr | null; descriptor: NativePtr | null } {
|
||||
const ownerSlot = allocPtrSlot()
|
||||
const groupSlot = allocPtrSlot()
|
||||
const daclSlot = allocPtrSlot()
|
||||
const saclSlot = allocPtrSlot()
|
||||
const descriptorSlot = allocPtrSlot()
|
||||
const readResult = api.getNamedSecurityInfoW(
|
||||
path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION,
|
||||
ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot,
|
||||
)
|
||||
if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path)
|
||||
return { oldAcl: decodePtr(daclSlot), descriptor: decodePtr(descriptorSlot) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl`
|
||||
* (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch),
|
||||
* free the descriptor before applying the merged ACL, apply it, then free the
|
||||
* merged ACL — checking every call and reporting with the caller's label.
|
||||
* @param api - the binding table.
|
||||
* @param path - the directory the DACL edit applies to.
|
||||
* @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke).
|
||||
* @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}).
|
||||
* @param descriptor - the descriptor allocation owning `oldAcl`.
|
||||
* @param label - the caller's name for error details.
|
||||
*/
|
||||
function mergeAndApply(
|
||||
api: Win32Bindings,
|
||||
path: string,
|
||||
entry: Buffer,
|
||||
oldAcl: NativePtr | null,
|
||||
descriptor: NativePtr | null,
|
||||
label: string,
|
||||
): void {
|
||||
const newAclSlot = allocPtrSlot()
|
||||
const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot)
|
||||
if (mergeResult !== abi.ERROR_SUCCESS) {
|
||||
if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too
|
||||
throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`)
|
||||
}
|
||||
const newAcl = decodePtr(newAclSlot)
|
||||
if (newAcl === null) {
|
||||
if (descriptor !== null) api.localFree(descriptor)
|
||||
throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `${label}(${path}): null new ACL`)
|
||||
}
|
||||
|
||||
// The descriptor block (oldAcl included) is dead after the merge — free it
|
||||
// before applying, exactly like the POC.
|
||||
const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null
|
||||
const applyResult = api.setNamedSecurityInfoW(
|
||||
path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION,
|
||||
null, null, newAcl, null,
|
||||
)
|
||||
const freedNew = api.localFree(newAcl)
|
||||
if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`)
|
||||
if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`)
|
||||
if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the explicit DACL already carries the EXACT write grant this
|
||||
* module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the
|
||||
* orphan SID). Every field is read through koffi.decode at pointer offsets —
|
||||
* no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the
|
||||
* ACE after the 4-byte mask — there is no pointer to read; reading one
|
||||
* yields garbage addresses and crashed EqualSid, verified by gdb), so it is
|
||||
* compared field-by-field against the orphan SID through bounded offset
|
||||
* reads ({@link sameSidAt}). A malformed header reads as "no exact grant"
|
||||
* so the caller falls back to the merge-apply path, which owns the robust
|
||||
* failure handling.
|
||||
* @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}).
|
||||
* @param sidPtr - the orphan write SID to match.
|
||||
* @returns whether the exact grant ACE is already present.
|
||||
*/
|
||||
function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean {
|
||||
const aclSize = decodeUint16At(oldAcl, 2)
|
||||
const aceCount = decodeUint16At(oldAcl, 4)
|
||||
if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path
|
||||
let offset = 8 // the first ACE follows the 8-byte ACL header
|
||||
for (let index = 0; index < aceCount; index++) {
|
||||
// ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD);
|
||||
// ACCESS_ALLOWED_ACE: Mask@4, inline SID@8.
|
||||
const aceSize = decodeUint16At(oldAcl, offset + 2)
|
||||
if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path
|
||||
const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE
|
||||
&& decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT
|
||||
&& decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK
|
||||
if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true
|
||||
offset += aceSize
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID
|
||||
* on `path`, inheriting to subcontainers and objects. Idempotent: when the
|
||||
* directory's current explicit DACL already carries the exact ACE (the
|
||||
* per-session grant surviving from a previous server lifetime), the
|
||||
* SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate
|
||||
* the identical ACE across the whole tree (eager inheritance; minutes on
|
||||
* large workspaces). Otherwise read-merge-write: the new ACE merges into the
|
||||
* directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so
|
||||
* pre-existing explicit ACEs survive. Runs under the per-path lock. The
|
||||
* directory must be owned by the caller (owner implicit WRITE_DAC) — same
|
||||
* precondition as the POC.
|
||||
* @param api - the binding table.
|
||||
* @param path - the directory whose DACL gains the grant (the workspace or temp root).
|
||||
* @param sidPtr - the orphan write SID the ACE names.
|
||||
*/
|
||||
export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void {
|
||||
withPathLock(api, path, () => {
|
||||
const { oldAcl, descriptor } = readCurrentDacl(api, path)
|
||||
if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) {
|
||||
// The exact ACE stands: releasing the descriptor is the whole operation.
|
||||
if (descriptor !== null) {
|
||||
const freed = api.localFree(descriptor)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`)
|
||||
}
|
||||
return
|
||||
}
|
||||
mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS
|
||||
* merge — other entries are preserved). Returns whether an ACE removal was
|
||||
* attempted (false when the directory carries no DACL at all).
|
||||
*
|
||||
* Runs under the per-path lock (the whole get-merge-set sequence); the
|
||||
* descriptor/ACL allocation contract lives on {@link readCurrentDacl}.
|
||||
* @param api - the binding table.
|
||||
* @param path - the directory whose DACL loses the orphan-SID ACEs.
|
||||
* @param sidPtr - the orphan write SID whose ACEs are removed.
|
||||
* @returns whether an ACE removal was attempted (false when the directory carries no DACL at all).
|
||||
*/
|
||||
export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean {
|
||||
return withPathLock(api, path, () => {
|
||||
const { oldAcl, descriptor } = readCurrentDacl(api, path)
|
||||
if (oldAcl === null) {
|
||||
if (descriptor !== null) {
|
||||
const freed = api.localFree(descriptor)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `revokeWrite(${path}) descriptor`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, descriptor, 'revokeWrite')
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Fail-closed Win32 error type. Every backend API failure raises this with the
|
||||
* API name and the exact Win32 code; the original POC silently ignored every
|
||||
* failed call and would run children UNRESTRICTED (fail-open) — that is the
|
||||
* failure mode this class exists to prevent.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/errors
|
||||
*/
|
||||
|
||||
export class Win32Error extends Error {
|
||||
/** The failing Win32 API name, e.g. `CreateRestrictedToken`. */
|
||||
readonly api: string
|
||||
/** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */
|
||||
readonly win32Code: number
|
||||
|
||||
constructor(api: string, win32Code: number, detail?: string) {
|
||||
super(`${api} failed (Win32 ${win32Code})${detail === undefined ? '' : `: ${detail}`}`)
|
||||
this.name = 'Win32Error'
|
||||
this.api = api
|
||||
this.win32Code = win32Code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Lazy koffi bindings for the Win32 ACL-sandbox backend. Koffi loads lazily so
|
||||
* non-Windows processes never open Win32 libraries. Every function signature
|
||||
* below was verified against the MinGW Windows headers on this machine
|
||||
* (winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h /
|
||||
* processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h);
|
||||
* struct layouts are asserted at load time against verify/abi-probe.cpp.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/ffi
|
||||
*/
|
||||
|
||||
import koffi from 'koffi'
|
||||
import { Win32Error } from './errors.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
/** Branded koffi 3 native pointer. Koffi 3 pointers are BigInt values; the brand keeps them out of numeric contexts. */
|
||||
declare const nativePtr: unique symbol
|
||||
/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */
|
||||
export type NativePtr = bigint & { readonly [nativePtr]: true }
|
||||
|
||||
/**
|
||||
* True for NULL pointers, however koffi returns them (null or 0n).
|
||||
* @param value - a pointer as koffi may hand it back (pointer, null, or 0n).
|
||||
* @returns a type guard narrowing to the NULL shapes.
|
||||
*/
|
||||
export function isNullPtr(value: NativePtr | null | undefined): value is null | undefined {
|
||||
return value === null || value === undefined || (value as bigint) === 0n
|
||||
}
|
||||
|
||||
/**
|
||||
* True for CreateFileW's INVALID_HANDLE_VALUE failure marker (-1, which
|
||||
* koffi hands back as the unsigned 64-bit all-ones pointer).
|
||||
* @param handle - the handle CreateFileW returned.
|
||||
* @returns whether the handle signals failure.
|
||||
*/
|
||||
export function isInvalidHandle(handle: NativePtr | null | undefined): boolean {
|
||||
if (isNullPtr(handle)) return true
|
||||
return (handle as bigint) === 0xFFFFFFFFFFFFFFFFn || (handle as bigint) === -1n
|
||||
}
|
||||
|
||||
type Ptr = ReturnType<typeof koffi.pointer>
|
||||
|
||||
/** Field subset written into a zeroed STARTUPINFOW (layout verified: size 104). */
|
||||
export interface StartupInfoInput {
|
||||
cb: number
|
||||
dwFlags: number
|
||||
hStdInput: NativePtr
|
||||
hStdOutput: NativePtr
|
||||
hStdError: NativePtr
|
||||
}
|
||||
|
||||
/** Decoded PROCESS_INFORMATION (layout verified: size 24). */
|
||||
export interface ProcessInfoOutput {
|
||||
hProcess: NativePtr | null
|
||||
hThread: NativePtr | null
|
||||
dwProcessId: number
|
||||
dwThreadId: number
|
||||
}
|
||||
|
||||
/** The lazy koffi binding table: every Win32 call the ACL backend uses, signature-verified against the real headers. */
|
||||
export interface Win32Bindings {
|
||||
// ---- process / token handles --------------------------------------------
|
||||
openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr
|
||||
openProcessToken(process: NativePtr, desiredAccess: number, tokenHandle: NativePtr): number
|
||||
closeHandle(handle: NativePtr): number
|
||||
// ---- errors / diagnostics ------------------------------------------------
|
||||
getLastError(): number
|
||||
formatMessageW(flags: number, source: null, messageId: number, languageId: number, buffer: Buffer, size: number, args: null): number
|
||||
// ---- memory --------------------------------------------------------------
|
||||
localAlloc(flags: number, bytes: number): NativePtr
|
||||
localFree(memory: NativePtr): NativePtr
|
||||
// ---- SIDs ----------------------------------------------------------------
|
||||
convertStringSidToSidW(stringSid: string, sid: NativePtr): number
|
||||
createWellKnownSid(type: number, domainSid: null, sid: NativePtr, size: NativePtr): number
|
||||
isValidSid(sid: NativePtr): number
|
||||
getLengthSid(sid: NativePtr): number
|
||||
copySid(length: number, destination: NativePtr, source: NativePtr): number
|
||||
// ---- token information ---------------------------------------------------
|
||||
getTokenInformation(token: NativePtr, cls: number, info: Buffer | null, length: number, needed: NativePtr): number
|
||||
setTokenInformation(token: NativePtr, cls: number, info: Buffer, length: number): number
|
||||
// ---- restricted token ----------------------------------------------------
|
||||
createRestrictedToken(
|
||||
existing: NativePtr, flags: number,
|
||||
disableCount: number, disableSids: null,
|
||||
deletePrivilegeCount: number, privilegesToDelete: null,
|
||||
restrictCount: number, restrictingSids: Buffer,
|
||||
newToken: NativePtr,
|
||||
): number
|
||||
// ---- ACL editing ---------------------------------------------------------
|
||||
setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number
|
||||
setNamedSecurityInfoW(
|
||||
path: string, objectType: number, information: number,
|
||||
owner: null, group: null, dacl: NativePtr | null, sacl: null,
|
||||
): number
|
||||
getNamedSecurityInfoW(
|
||||
path: string, objectType: number, information: number,
|
||||
owner: NativePtr, group: NativePtr, dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr,
|
||||
): number
|
||||
// ---- environment / io ----------------------------------------------------
|
||||
getTempPathW(length: number, buffer: Buffer): number
|
||||
createFileW(
|
||||
fileName: string, desiredAccess: number, shareMode: number, attributes: null,
|
||||
creationDisposition: number, flagsAndAttributes: number, templateFile: null,
|
||||
): NativePtr
|
||||
lockFileEx(file: NativePtr, flags: number, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number
|
||||
unlockFileEx(file: NativePtr, reserved: number, bytesLow: number, bytesHigh: number, overlapped: NativePtr): number
|
||||
createPipe(readHandle: NativePtr, writeHandle: NativePtr, attributes: null, size: number): number
|
||||
setHandleInformation(handle: NativePtr, mask: number, flags: number): number
|
||||
createProcessAsUserW(
|
||||
token: NativePtr, applicationName: null, commandLine: string,
|
||||
processAttributes: null, threadAttributes: null,
|
||||
inheritHandles: number, creationFlags: number, environment: null,
|
||||
currentDirectory: string | null, startupInfo: NativePtr, processInfo: NativePtr,
|
||||
): number
|
||||
setEnvironmentVariableW(name: string, value: string): number
|
||||
readFile(file: NativePtr, buffer: Buffer, count: number, bytesRead: NativePtr, overlapped: null): number
|
||||
peekNamedPipe(
|
||||
pipe: NativePtr, buffer: null, size: number,
|
||||
bytesRead: NativePtr, totalAvail: NativePtr, leftThisMessage: NativePtr,
|
||||
): number
|
||||
waitForSingleObject(handle: NativePtr, milliseconds: number): number
|
||||
getExitCodeProcess(process: NativePtr, exitCode: NativePtr): number
|
||||
resumeThread(thread: NativePtr): number
|
||||
// ---- job object (runner kill-on-close) -----------------------------------
|
||||
createJobObjectW(attributes: null, name: null): NativePtr
|
||||
setInformationJobObject(job: NativePtr, cls: number, information: Buffer, length: number): number
|
||||
assignProcessToJobObject(job: NativePtr, process: NativePtr): number
|
||||
// Terminate a suspended child that could not be placed in the kill-on-close
|
||||
// job — closing handles alone would leave it hanging forever.
|
||||
terminateProcess(process: NativePtr, exitCode: number): number
|
||||
// ---- console -------------------------------------------------------------
|
||||
// HandlerRoutine=null + add=1 makes this process ignore CTRL+C (wincon.h):
|
||||
// the runner survives console Ctrl+C so the child handles its own and the
|
||||
// runner can clean up grants after the child exits.
|
||||
setConsoleCtrlHandler(handler: null, add: number): number
|
||||
getStdHandle(stdHandle: number): NativePtr
|
||||
}
|
||||
|
||||
const PVOID: Ptr = koffi.pointer('void')
|
||||
const PPVOID: Ptr = koffi.pointer(PVOID)
|
||||
|
||||
/** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */
|
||||
export const STARTUPINFOW = koffi.struct('STARTUPINFOW', {
|
||||
cb: 'uint32',
|
||||
lpReserved: 'str16',
|
||||
lpDesktop: 'str16',
|
||||
lpTitle: 'str16',
|
||||
dwX: 'uint32',
|
||||
dwY: 'uint32',
|
||||
dwXSize: 'uint32',
|
||||
dwYSize: 'uint32',
|
||||
dwXCountChars: 'uint32',
|
||||
dwYCountChars: 'uint32',
|
||||
dwFillAttribute: 'uint32',
|
||||
dwFlags: 'uint32',
|
||||
wShowWindow: 'uint16',
|
||||
cbReserved2: 'uint16',
|
||||
lpReserved2: koffi.pointer('uint8'),
|
||||
hStdInput: PVOID,
|
||||
hStdOutput: PVOID,
|
||||
hStdError: PVOID,
|
||||
})
|
||||
|
||||
/** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */
|
||||
export const PROCESS_INFORMATION = koffi.struct('PROCESS_INFORMATION', {
|
||||
hProcess: PVOID,
|
||||
hThread: PVOID,
|
||||
dwProcessId: 'uint32',
|
||||
dwThreadId: 'uint32',
|
||||
})
|
||||
|
||||
if (STARTUPINFOW.size !== abi.STARTUPINFOW_SIZE) {
|
||||
throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says ${abi.STARTUPINFOW_SIZE}`)
|
||||
}
|
||||
if (PROCESS_INFORMATION.size !== abi.PROCESS_INFORMATION_SIZE) {
|
||||
throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says ${abi.PROCESS_INFORMATION_SIZE}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate one pointer-sized slot (for `T **` out-parameters).
|
||||
* @returns the allocated slot pointer.
|
||||
*/
|
||||
export function allocPtrSlot(): NativePtr {
|
||||
const value: unknown = koffi.alloc(PVOID, 1)
|
||||
return value as NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate one uint32 slot.
|
||||
* @returns the allocated slot pointer.
|
||||
*/
|
||||
export function allocUint32(): NativePtr {
|
||||
const value: unknown = koffi.alloc('uint32', 1)
|
||||
return value as NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a uint32 value into a slot pointer.
|
||||
* @param slot - the slot allocated by {@link allocUint32}.
|
||||
* @param value - the uint32 to encode.
|
||||
*/
|
||||
export function encodeUint32(slot: NativePtr, value: number): void {
|
||||
koffi.encode(slot, 'uint32', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the pointer stored in a pointer-sized slot (NULL becomes null).
|
||||
* @param slot - the pointer-sized slot holding the out-parameter value.
|
||||
* @returns the decoded pointer, or null for NULL.
|
||||
*/
|
||||
export function decodePtr(slot: NativePtr): NativePtr | null {
|
||||
const value: unknown = koffi.decode(slot, PVOID)
|
||||
if (isNullPtr(value as NativePtr | null | undefined)) return null
|
||||
return value as NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a uint32 at a slot pointer.
|
||||
* @param slot - the uint32 slot holding the out-parameter value.
|
||||
* @returns the decoded uint32.
|
||||
*/
|
||||
export function decodeUint32(slot: NativePtr): number {
|
||||
const value: unknown = koffi.decode(slot, 'uint32')
|
||||
return value as number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a koffi pointer to its numeric address (bigint, used for raw struct packing).
|
||||
* @param ptr - the koffi pointer.
|
||||
* @returns the pointer's numeric address.
|
||||
*/
|
||||
export function ptrAddress(ptr: NativePtr): bigint {
|
||||
return koffi.address(ptr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a raw byte block (used for SID copies and variable-length arrays).
|
||||
* @param length - the block size in bytes.
|
||||
* @returns the allocated block pointer.
|
||||
*/
|
||||
export function allocBytes(length: number): NativePtr {
|
||||
const value: unknown = koffi.alloc('uint8', length)
|
||||
return value as NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate one zeroed OVERLAPPED (32 bytes on x64: Internal@0, InternalHigh@8,
|
||||
* Offset@16, OffsetHigh@20, hEvent@24). LockFileEx/UnlockFileEx receive this
|
||||
* instead of a NULL lpOverlapped: koffi 3.1.1 crashes on NULL there, and a
|
||||
* zeroed OVERLAPPED on a synchronous file handle is the documented equivalent
|
||||
* (the byte range locks from offset 0, hEvent stays NULL).
|
||||
* @returns the zeroed block pointer.
|
||||
*/
|
||||
export function allocOverlapped(): NativePtr {
|
||||
return allocBytes(32)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries).
|
||||
* @param buffer - the buffer holding the pointer value.
|
||||
* @param offset - byte offset of the pointer inside the buffer.
|
||||
* @returns the decoded pointer, or null for NULL.
|
||||
*/
|
||||
export function decodePtrAt(buffer: Buffer, offset: number): NativePtr | null {
|
||||
const value: unknown = koffi.decode(buffer, offset, PVOID)
|
||||
if (isNullPtr(value as NativePtr | null | undefined)) return null
|
||||
return value as NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a uint8 at a native pointer plus byte offset — the ACL walk's
|
||||
* field-read primitive (koffi.decode with an offset, no memcpy, no pointer
|
||||
* arithmetic).
|
||||
* @param ptr - the native pointer to read from.
|
||||
* @param offset - byte offset from the pointer.
|
||||
* @returns the decoded uint8.
|
||||
*/
|
||||
export function decodeUint8At(ptr: NativePtr, offset: number): number {
|
||||
const value: unknown = koffi.decode(ptr, offset, 'uint8')
|
||||
return value as number
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a uint16 at a native pointer plus byte offset (see {@link decodeUint8At}).
|
||||
* @param ptr - the native pointer to read from.
|
||||
* @param offset - byte offset from the pointer.
|
||||
* @returns the decoded uint16.
|
||||
*/
|
||||
export function decodeUint16At(ptr: NativePtr, offset: number): number {
|
||||
const value: unknown = koffi.decode(ptr, offset, 'uint16')
|
||||
return value as number
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a uint32 at a native pointer plus byte offset (see {@link decodeUint8At}).
|
||||
* @param ptr - the native pointer to read from.
|
||||
* @param offset - byte offset from the pointer.
|
||||
* @returns the decoded uint32.
|
||||
*/
|
||||
export function decodeUint32At(ptr: NativePtr, offset: number): number {
|
||||
const value: unknown = koffi.decode(ptr, offset, 'uint32')
|
||||
return value as number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two SIDs field-by-field via BOUNDED offset reads (revision, count,
|
||||
* identifier authority, subauthorities up to the count) — never a fixed-size
|
||||
* struct decode, which would read past a short SID allocation (a SID with
|
||||
* fewer than 8 subauthorities is smaller than `SID_STRUCT`). An implausible
|
||||
* subauthority count reads as unequal.
|
||||
* @param left - pointer to one SID (offset 0).
|
||||
* @param leftOffset - byte offset of the SID structure within `left`.
|
||||
* @param right - pointer to the other SID.
|
||||
* @param rightOffset - byte offset of the SID structure within `right`.
|
||||
* @returns whether the SIDs are identical.
|
||||
*/
|
||||
export function sameSidAt(left: NativePtr, leftOffset: number, right: NativePtr, rightOffset: number): boolean {
|
||||
const leftRevision = decodeUint8At(left, leftOffset)
|
||||
const rightRevision = decodeUint8At(right, rightOffset)
|
||||
if (leftRevision !== rightRevision) return false
|
||||
const leftCount = decodeUint8At(left, leftOffset + 1)
|
||||
const rightCount = decodeUint8At(right, rightOffset + 1)
|
||||
if (leftCount !== rightCount || leftCount > abi.SID_MAX_SUB_AUTHORITIES) return false
|
||||
for (let index = 0; index < 6; index++) {
|
||||
if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) return false
|
||||
}
|
||||
for (let index = 0; index < leftCount; index++) {
|
||||
if (decodeUint32At(left, leftOffset + 8 + index * 4) !== decodeUint32At(right, rightOffset + 8 + index * 4)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a zeroed STARTUPINFOW.
|
||||
* @returns the allocated struct pointer.
|
||||
*/
|
||||
export function allocStartupInfo(): NativePtr {
|
||||
const value: unknown = koffi.alloc(STARTUPINFOW, 1)
|
||||
return value as NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized).
|
||||
* @param startupInfo - the allocated STARTUPINFOW to encode into.
|
||||
* @param fields - the field subset to write.
|
||||
*/
|
||||
export function encodeStartupInfo(startupInfo: NativePtr, fields: StartupInfoInput): void {
|
||||
koffi.encode(startupInfo, STARTUPINFOW, fields)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a zeroed PROCESS_INFORMATION.
|
||||
* @returns the allocated struct pointer.
|
||||
*/
|
||||
export function allocProcessInfo(): NativePtr {
|
||||
const value: unknown = koffi.alloc(PROCESS_INFORMATION, 1)
|
||||
return value as NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a PROCESS_INFORMATION after CreateProcessAsUserW.
|
||||
* @param processInfo - the PROCESS_INFORMATION filled by the spawn call.
|
||||
* @returns the decoded handle/id fields.
|
||||
*/
|
||||
export function decodeProcessInfo(processInfo: NativePtr): ProcessInfoOutput {
|
||||
const value: unknown = koffi.decode(processInfo, PROCESS_INFORMATION)
|
||||
return value as ProcessInfoOutput
|
||||
}
|
||||
|
||||
let cached: Win32Bindings | undefined
|
||||
|
||||
function bindings(): Win32Bindings {
|
||||
if (cached !== undefined) return cached
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const advapi32 = koffi.load('advapi32.dll')
|
||||
|
||||
// Each binding shape is verified by verify/abi-probe.cpp against the real
|
||||
// Windows headers and exercised end-to-end by tests/probe.spec.ts; the
|
||||
// single cast keeps the per-binding noise out of this table.
|
||||
const bind = (lib: ReturnType<typeof koffi.load>, name: string, result: Ptr | string, args: Array<Ptr | string>): unknown =>
|
||||
lib.func('__stdcall', name, result, args)
|
||||
|
||||
cached = {
|
||||
openProcess: bind(kernel32, 'OpenProcess', PVOID, ['uint32', 'int', 'uint32']),
|
||||
openProcessToken: bind(advapi32, 'OpenProcessToken', 'int', [PVOID, 'uint32', PPVOID]),
|
||||
closeHandle: bind(kernel32, 'CloseHandle', 'int', [PVOID]),
|
||||
getLastError: bind(kernel32, 'GetLastError', 'uint32', []),
|
||||
formatMessageW: bind(kernel32, 'FormatMessageW', 'uint32', ['uint32', PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID]),
|
||||
localAlloc: bind(kernel32, 'LocalAlloc', PVOID, ['uint32', 'size_t']),
|
||||
localFree: bind(kernel32, 'LocalFree', PVOID, [PVOID]),
|
||||
convertStringSidToSidW: bind(advapi32, 'ConvertStringSidToSidW', 'int', ['str16', PPVOID]),
|
||||
createWellKnownSid: bind(advapi32, 'CreateWellKnownSid', 'int', ['int', PVOID, PVOID, koffi.pointer('uint32')]),
|
||||
isValidSid: bind(advapi32, 'IsValidSid', 'int', [PVOID]),
|
||||
getLengthSid: bind(advapi32, 'GetLengthSid', 'uint32', [PVOID]),
|
||||
copySid: bind(advapi32, 'CopySid', 'int', ['uint32', PVOID, PVOID]),
|
||||
getTokenInformation: bind(advapi32, 'GetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32', koffi.pointer('uint32')]),
|
||||
setTokenInformation: bind(advapi32, 'SetTokenInformation', 'int', [PVOID, 'int', PVOID, 'uint32']),
|
||||
createRestrictedToken: bind(advapi32, 'CreateRestrictedToken', 'int', [PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID]),
|
||||
setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]),
|
||||
setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID]),
|
||||
getNamedSecurityInfoW: bind(advapi32, 'GetNamedSecurityInfoW', 'uint32', ['str16', 'int', 'uint32', PPVOID, PPVOID, PPVOID, PPVOID, PPVOID]),
|
||||
getTempPathW: bind(kernel32, 'GetTempPathW', 'uint32', ['uint32', PVOID]),
|
||||
// fileapi.h line ~64: HANDLE CreateFileW(LPCWSTR, DWORD, DWORD,
|
||||
// LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE).
|
||||
createFileW: bind(kernel32, 'CreateFileW', PVOID, ['str16', 'uint32', 'uint32', PVOID, 'uint32', 'uint32', PVOID]),
|
||||
// fileapi.h lines ~177/~185: BOOL LockFileEx(HANDLE, DWORD, DWORD, DWORD,
|
||||
// DWORD, LPOVERLAPPED); BOOL UnlockFileEx(HANDLE, DWORD, DWORD, DWORD,
|
||||
// LPOVERLAPPED). lpOverlapped is NULL for synchronous locking.
|
||||
lockFileEx: bind(kernel32, 'LockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', 'uint32', PVOID]),
|
||||
unlockFileEx: bind(kernel32, 'UnlockFileEx', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]),
|
||||
createPipe: bind(kernel32, 'CreatePipe', 'int', [PPVOID, PPVOID, PVOID, 'uint32']),
|
||||
setHandleInformation: bind(kernel32, 'SetHandleInformation', 'int', [PVOID, 'uint32', 'uint32']),
|
||||
createProcessAsUserW: bind(advapi32, 'CreateProcessAsUserW', 'int', [
|
||||
PVOID, 'str16', 'str16', PVOID, PVOID, 'int', 'uint32', PVOID, 'str16',
|
||||
koffi.pointer(STARTUPINFOW), koffi.pointer(PROCESS_INFORMATION),
|
||||
]),
|
||||
setEnvironmentVariableW: bind(kernel32, 'SetEnvironmentVariableW', 'int', ['str16', 'str16']),
|
||||
readFile: bind(kernel32, 'ReadFile', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), PVOID]),
|
||||
peekNamedPipe: bind(kernel32, 'PeekNamedPipe', 'int', [PVOID, PVOID, 'uint32', koffi.pointer('uint32'), koffi.pointer('uint32'), koffi.pointer('uint32')]),
|
||||
waitForSingleObject: bind(kernel32, 'WaitForSingleObject', 'uint32', [PVOID, 'uint32']),
|
||||
getExitCodeProcess: bind(kernel32, 'GetExitCodeProcess', 'int', [PVOID, koffi.pointer('uint32')]),
|
||||
resumeThread: bind(kernel32, 'ResumeThread', 'uint32', [PVOID]),
|
||||
createJobObjectW: bind(kernel32, 'CreateJobObjectW', PVOID, [PVOID, 'str16']),
|
||||
setInformationJobObject: bind(kernel32, 'SetInformationJobObject', 'int', [PVOID, 'int', PVOID, 'uint32']),
|
||||
assignProcessToJobObject: bind(kernel32, 'AssignProcessToJobObject', 'int', [PVOID, PVOID]),
|
||||
terminateProcess: bind(kernel32, 'TerminateProcess', 'int', [PVOID, 'uint32']),
|
||||
setConsoleCtrlHandler: bind(kernel32, 'SetConsoleCtrlHandler', 'int', [PVOID, 'int']),
|
||||
getStdHandle: bind(kernel32, 'GetStdHandle', PVOID, ['int']),
|
||||
} as unknown as Win32Bindings
|
||||
return cached
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed).
|
||||
* @returns the cached binding table.
|
||||
*/
|
||||
export function win32(): Promise<Win32Bindings> {
|
||||
return Promise.resolve(bindings())
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's
|
||||
* server-side per-session grant materializes ACEs inside the synchronous
|
||||
* `confine()` call, which cannot await. Same cached table as {@link win32}
|
||||
* (the underlying koffi loads are synchronous; the async wrapper exists for
|
||||
* the runner's await-shaped call sites).
|
||||
* @returns the cached binding table.
|
||||
*/
|
||||
export function win32Sync(): Win32Bindings {
|
||||
return bindings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a Win32 error code into readable text via FormatMessageW.
|
||||
* @param api - the binding table.
|
||||
* @param win32Code - the error code to format.
|
||||
* @returns the formatted message text, or '' when formatting fails.
|
||||
*/
|
||||
export function errorText(api: Win32Bindings, win32Code: number): string {
|
||||
const buffer = Buffer.alloc(1024)
|
||||
const length = api.formatMessageW(
|
||||
abi.FORMAT_MESSAGE_FROM_SYSTEM | abi.FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
null, win32Code, 0, buffer, buffer.length / 2, null,
|
||||
)
|
||||
if (length === 0) return ''
|
||||
return buffer.subarray(0, length * 2).toString('utf16le').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the process temp directory via GetTempPathW (fileapi.h line ~188).
|
||||
* Defensive against an overlong system temp path: GetTempPathW reports the
|
||||
* REQUIRED length (including NUL) without writing the buffer when it is too
|
||||
* small, so a reported length beyond the buffer's capacity means the buffer
|
||||
* was never filled and must not be decoded.
|
||||
* @param api - the binding table.
|
||||
* @returns the NUL-terminated temp path decoded as a string.
|
||||
*/
|
||||
export function getTempPath(api: Win32Bindings): string {
|
||||
const buffer = Buffer.alloc((abi.MAX_PATH + 1) * 2)
|
||||
const length = api.getTempPathW(buffer.length / 2, buffer)
|
||||
if (length === 0) throwLastError(api, 'GetTempPathW')
|
||||
if (length > buffer.length / 2) {
|
||||
throw new Win32Error('GetTempPathW', abi.ERROR_INSUFFICIENT_BUFFER, `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`)
|
||||
}
|
||||
return buffer.subarray(0, length * 2).toString('utf16le')
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a Win32Error for a BOOL-style API failure. MUST be called immediately
|
||||
* after the failed call so GetLastError is not clobbered by other Win32 calls.
|
||||
* @param api - the binding table.
|
||||
* @param name - the failed API's name for the error message.
|
||||
* @param detail - optional detail overriding the formatted system message.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
export function throwLastError(api: Win32Bindings, name: string, detail?: string): never {
|
||||
const win32Code = api.getLastError()
|
||||
throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code))
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a Win32Error for an HRESULT-style API return value (the value IS the error code).
|
||||
* @param api - the binding table.
|
||||
* @param name - the failed API's name for the error message.
|
||||
* @param win32Code - the API's returned error code.
|
||||
* @param detail - optional detail overriding the formatted system message.
|
||||
* @returns never — always throws.
|
||||
*/
|
||||
export function throwWin32(api: Win32Bindings, name: string, win32Code: number, detail?: string): never {
|
||||
throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Server-side per-session write grant: the ACE materialization half of the
|
||||
* sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE
|
||||
* {@link AclWriteGrant} per session for the server process's lifetime —
|
||||
* created lazily at the session's first confined execution, reused (never
|
||||
* re-applied) for every later call, revoked on provider dispose. The durable
|
||||
* half (the session's SID and paths surviving a restart) lives in the
|
||||
* session log, owned by the seam; this module owns only the native half: the
|
||||
* parsed SID pointer and the standing ACEs.
|
||||
*
|
||||
* Fail-closed: `add` throws on any grant failure and the caller disposes the
|
||||
* instance (revoking every path granted so far); `dispose` revokes every
|
||||
* standing grant and reports every cleanup failure.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/grant
|
||||
*/
|
||||
|
||||
import { grantWrite, revokeWrite } from './acl.ts'
|
||||
import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
|
||||
/**
|
||||
* One write SID's server-lifetime grant materialization: the parsed SID
|
||||
* pointer plus every directory whose DACL currently carries its ACE.
|
||||
* Workspace paths are added STANDING (their ACEs are the cross-session reuse
|
||||
* cache and outlive the grant — dispose() skips revoking them, or the next
|
||||
* provision would re-propagate the whole tree); temp paths are revocable
|
||||
* (dispose() revokes them — an inheritable ACE must not outlive its
|
||||
* session's temp directory). Create with {@link AclWriteGrant.create};
|
||||
* dispose revokes the revocable paths and frees the SID.
|
||||
*/
|
||||
export class AclWriteGrant {
|
||||
/** The write SID in SDDL string form. */
|
||||
readonly writeSid: string
|
||||
private readonly api: Win32Bindings
|
||||
private readonly sidPtr: NativePtr
|
||||
private readonly revocablePaths: string[] = []
|
||||
private readonly standingPaths: string[] = []
|
||||
|
||||
private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) {
|
||||
this.api = api
|
||||
this.sidPtr = sidPtr
|
||||
this.writeSid = writeSid
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the SID string and open the binding table (lazily, once per
|
||||
* server). Fail-closed: any failure throws — nothing is granted yet.
|
||||
* @param writeSid - the orphan write SID string (`S-1-4-x-y`).
|
||||
* @param api - optional already-resolved bindings (tests).
|
||||
* @returns the ready grant (no ACEs yet).
|
||||
*/
|
||||
static create(writeSid: string, api?: Win32Bindings): AclWriteGrant {
|
||||
const bindings = api ?? win32Sync()
|
||||
const sidSlot = allocPtrSlot()
|
||||
if (bindings.convertStringSidToSidW(writeSid, sidSlot) === 0) {
|
||||
throwLastError(bindings, 'ConvertStringSidToSidW', writeSid)
|
||||
}
|
||||
const sidPtr = decodePtr(sidSlot)
|
||||
if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`)
|
||||
return new AclWriteGrant(bindings, sidPtr, writeSid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant the write ACE on one directory (idempotent: an already-standing
|
||||
* exact ACE skips the eager full-tree re-propagation — see
|
||||
* {@link grantWrite}) and record the path for {@link dispose} unless it is
|
||||
* standing. The path is recorded BEFORE the grant: a post-apply throw (a
|
||||
* LocalFree failure after SetNamedSecurityInfoW succeeded) must still
|
||||
* revoke it, and revoking an ungranted path is a no-op merge. Callers
|
||||
* treat a throw as a failed materialization and dispose the instance to
|
||||
* revoke the paths granted so far.
|
||||
* @param path - the directory whose DACL gains the grant.
|
||||
* @param standing - the ACE outlives this grant (the workspace reuse
|
||||
* cache; dispose() skips revoking it). Default false (revoked on
|
||||
* dispose — the temp-directory lifecycle).
|
||||
*/
|
||||
add(path: string, standing = false): void {
|
||||
;(standing ? this.standingPaths : this.revocablePaths).push(path)
|
||||
grantWrite(this.api, path, this.sidPtr)
|
||||
}
|
||||
|
||||
/** Every directory currently carrying the grant, in grant order. */
|
||||
get paths(): readonly string[] {
|
||||
return [...this.standingPaths, ...this.revocablePaths]
|
||||
}
|
||||
|
||||
/** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */
|
||||
dispose(): void {
|
||||
const failures: unknown[] = []
|
||||
for (const path of this.revocablePaths) {
|
||||
try {
|
||||
revokeWrite(this.api, path, this.sidPtr)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const freed = this.api.localFree(this.sidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', 'write SID')
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Windows ACL write-restriction sandbox backend for the DeepSeek Harness
|
||||
* sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/
|
||||
* windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED
|
||||
* token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only
|
||||
* this sandbox adds to the target directories' DACLs — the intersection
|
||||
* check then allows writes exactly where that SID has a Write ACE, and
|
||||
* nowhere else the write SID is concerned (the token's write check ALSO
|
||||
* inherits the ambient write ACEs of the other restricting SIDs — the
|
||||
* keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE,
|
||||
* and LOCAL are absent from both lists — see the seam's dual-list contract
|
||||
* in `packages/sandbox/sandbox-local` and the package README's Modes section
|
||||
* for the complete boundary). The write SID is the per-WORKSPACE identity
|
||||
* ({@link workspaceWriteSid}): deterministic from the canonical workspace
|
||||
* path, so the workspace-root ACE materializes once per workspace per
|
||||
* machine and every later provision hits the exact-ACE skip — the
|
||||
* grant-reuse story the per-session random SID paid a full tree propagation
|
||||
* per session for. Unlike the POC, every API failure throws with the API
|
||||
* name and exact Win32 code; a child is NEVER spawned unrestricted.
|
||||
*
|
||||
* Known boundaries (inherent to restricted tokens, not this port):
|
||||
* - writes are restricted; reads, network, and process visibility are NOT
|
||||
* (WRITE_RESTRICTED intersects only write accesses);
|
||||
* - console isolation is unavailable — children share the host console
|
||||
* (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with
|
||||
* STATUS_DLL_INIT_FAILED under the restriction);
|
||||
* - the temp directory and every writable directory must be owned by the
|
||||
* caller (owner-implicit WRITE_DAC);
|
||||
* - grants are standing ACE mutations on real directories. WORKSPACE grants
|
||||
* are deliberately never revoked — the ACE is the cross-session reuse
|
||||
* cache (revoking would force the next session to re-propagate the whole
|
||||
* tree). TEMP grants are revocable: dispose() removes them so a standing
|
||||
* inheritable ACE never outlives its session's temp directory (an
|
||||
* inheritable ACE on the ambient temp root would otherwise widen the
|
||||
* SID's write reach to every future temp file). With `manageDacls: false`
|
||||
* the CALLER owns the DACLs (the sandbox seam's grant reuse):
|
||||
* init()/dispose() skip grant/revoke entirely and the caller must not
|
||||
* revoke under live children.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { grantWrite, revokeWrite } from './acl.ts'
|
||||
import { Win32Error } from './errors.ts'
|
||||
import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts'
|
||||
import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
export { quoteArg } from './spawn.ts'
|
||||
export { AclWriteGrant } from './grant.ts'
|
||||
export { workspaceWriteSid } from './workspace-sid.ts'
|
||||
export { Win32Error } from './errors.ts'
|
||||
|
||||
/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */
|
||||
export interface AclSandboxOptions {
|
||||
/** Directories the confined child may write into (must exist and be caller-owned). */
|
||||
writableDirs: readonly string[]
|
||||
/**
|
||||
* Temp directory to also grant; defaults to GetTempPathW() at init time.
|
||||
* Pass null for read-only confinement: NO temp grant (strict zero grant on
|
||||
* the filesystem; the NUL device stays ambient-writable via Everyone — see
|
||||
* README).
|
||||
*/
|
||||
tempDir?: string | null
|
||||
/**
|
||||
* The write SID forming the workspace-write allowlist: REQUIRED under
|
||||
* workspace-write, ignored (and must be absent) under read-only. Callers
|
||||
* derive it from the workspace via {@link workspaceWriteSid} — the identity
|
||||
* is per workspace, not per sandbox instance, so the workspace-root ACE
|
||||
* outlives every instance and later provisions hit the exact-ACE skip.
|
||||
*/
|
||||
writeSid?: string
|
||||
/**
|
||||
* The file-effect mode this instance confines under — selects the
|
||||
* restricted token's restricting-SID list (I for read-only, J for
|
||||
* workspace-write) and MUST match the grant shape: read-only pairs with
|
||||
* zero grants. The runner validates the argv-borne mode string at its
|
||||
* boundary; this typed seam trusts the union.
|
||||
*/
|
||||
mode: 'read-only' | 'workspace-write'
|
||||
/**
|
||||
* Whether this instance owns its DACL grants (default true). False means
|
||||
* the CALLER has already materialized the ACEs (the sandbox seam's
|
||||
* per-session grant reuse): init()/dispose() skip grant/revoke entirely —
|
||||
* the caller holds the grants for its own lifetime and revokes them.
|
||||
*/
|
||||
manageDacls?: boolean
|
||||
}
|
||||
|
||||
/** Per-spawn options: the program, its argv/cwd, and the stdio shape. */
|
||||
export interface AclSandboxSpawnOptions {
|
||||
/** Program to run (resolved via PATH search when unqualified, like CreateProcess). */
|
||||
command: string
|
||||
/** Arguments, quoted per CommandLineToArgvW rules. */
|
||||
args?: readonly string[]
|
||||
/** Working directory; defaults to the caller's cwd. */
|
||||
cwd?: string
|
||||
/**
|
||||
* 'pipe' (default): capture stdout/stderr via anonymous pipes.
|
||||
* 'inherit': the child inherits the caller's stdio directly (runner usage —
|
||||
* bytes flow straight through), always wrapped in a kill-on-close job so the
|
||||
* child dies with the caller; stdout/stderr in the result are empty.
|
||||
*/
|
||||
stdio?: 'pipe' | 'inherit'
|
||||
}
|
||||
|
||||
/** A settled confined child: captured stdio and the exit code. */
|
||||
export interface AclSandboxChildResult {
|
||||
stdout: Buffer
|
||||
stderr: Buffer
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
/** A running confined child: its pid and a settlement promise. */
|
||||
export interface AclSandboxChild {
|
||||
/** Child process id. */
|
||||
pid: number
|
||||
/** Resolve stdout/stderr and the exit code once the child exits. */
|
||||
wait(): Promise<AclSandboxChildResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* One write-restricted sandbox instance: token + write-SID grants + spawn.
|
||||
* `init()` is fail-closed — any Win32 failure revokes the revocable (temp)
|
||||
* grants and throws; `dispose()` revokes the temp grants, leaves the
|
||||
* standing workspace ACEs in place (the cross-instance reuse cache), frees
|
||||
* every allocation, and reports every cleanup failure. With
|
||||
* `manageDacls: false` the caller owns the grants (the sandbox seam's grant
|
||||
* reuse): init() applies none and dispose() revokes none.
|
||||
*/
|
||||
export class AclSandbox {
|
||||
/** Absolute writable directories (constructor-validated). */
|
||||
readonly writableDirs: string[]
|
||||
/** The write SID string whose ACEs form the write allowlist (workspace-write only). */
|
||||
readonly writeSid: string | undefined
|
||||
/** The file-effect mode — the restricted token's restricting-SID list selection. */
|
||||
readonly mode: 'read-only' | 'workspace-write'
|
||||
private readonly tempDirOption: string | null | undefined
|
||||
private readonly manageDacls: boolean
|
||||
private tempDirResolved: string | null | undefined
|
||||
private api: Win32Bindings | undefined
|
||||
private token: NativePtr | undefined
|
||||
private writeSidPtr: NativePtr | undefined
|
||||
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */
|
||||
private sidAllocations: NativePtr[] = []
|
||||
private grantedPaths: string[] = []
|
||||
|
||||
constructor(options: AclSandboxOptions) {
|
||||
this.mode = options.mode
|
||||
this.manageDacls = options.manageDacls ?? true
|
||||
this.writableDirs = options.writableDirs.map((directory) => {
|
||||
const absolute = resolve(directory)
|
||||
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
|
||||
throw new Error(`AclSandbox writable dir does not exist or is not a directory: ${absolute}`)
|
||||
}
|
||||
return absolute
|
||||
})
|
||||
this.tempDirOption = options.tempDir
|
||||
this.writeSid = options.writeSid
|
||||
if (this.mode === 'workspace-write' && this.writeSid === undefined) {
|
||||
throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()')
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved temp directory (available after init; null when temp grants are disabled). */
|
||||
get tempDir(): string | null | undefined {
|
||||
return this.tempDirResolved
|
||||
}
|
||||
|
||||
/** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */
|
||||
async init(): Promise<void> {
|
||||
if (this.api !== undefined) throw new Error('AclSandbox is already initialized')
|
||||
const api = await win32()
|
||||
|
||||
const currentToken = openCurrentProcessToken(api)
|
||||
try {
|
||||
// Read-only runs carry no write SID (its restricting list has no
|
||||
// orphan): nothing to parse, nothing to grant.
|
||||
let writeSidPtr: NativePtr | undefined
|
||||
if (this.writeSid !== undefined) {
|
||||
const sidSlot = allocPtrSlot()
|
||||
if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) {
|
||||
throwLastError(api, 'ConvertStringSidToSidW', this.writeSid)
|
||||
}
|
||||
const parsedSid = decodePtr(sidSlot)
|
||||
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid)
|
||||
this.writeSidPtr = parsedSid
|
||||
writeSidPtr = parsedSid
|
||||
}
|
||||
|
||||
const tempDir = this.tempDirOption === null
|
||||
? null
|
||||
: this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api)
|
||||
if (tempDir !== null) {
|
||||
if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) {
|
||||
throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`)
|
||||
}
|
||||
this.tempDirResolved = tempDir
|
||||
}
|
||||
|
||||
// manageDacls: false — the caller (the sandbox seam's grant) already
|
||||
// materialized the ACEs; this instance must neither add nor remove any.
|
||||
// When this instance owns the DACLs, writableDir ACEs are STANDING (the
|
||||
// per-workspace reuse cache — dispose() never revokes them, or the next
|
||||
// provision would re-propagate the whole tree) and the temp ACE is
|
||||
// REVOCABLE (dispose() removes it — an inheritable ACE on the ambient
|
||||
// temp root must not outlive the instance, or it would widen the SID's
|
||||
// write reach to every future temp file).
|
||||
if (this.manageDacls) {
|
||||
if (writeSidPtr !== undefined) {
|
||||
for (const path of this.writableDirs) {
|
||||
grantWrite(api, path, writeSidPtr)
|
||||
}
|
||||
if (tempDir !== null) {
|
||||
// Record BEFORE granting: grantWrite can throw after a successful
|
||||
// apply (a LocalFree failure), and the fail-closed catch must still
|
||||
// revoke that path (revoking an ungranted path is a no-op merge).
|
||||
this.grantedPaths.push(tempDir)
|
||||
grantWrite(api, tempDir, writeSidPtr)
|
||||
}
|
||||
}
|
||||
}
|
||||
const logonSid = findLogonSid(api, currentToken)
|
||||
this.sidAllocations.push(logonSid)
|
||||
const worldSid = makeWellKnownSid(api, abi.WinWorldSid)
|
||||
this.sidAllocations.push(worldSid)
|
||||
const restricted = createRestrictedToken(
|
||||
api, currentToken, logonSid, writeSidPtr,
|
||||
{ world: worldSid },
|
||||
this.mode,
|
||||
)
|
||||
// The restricted token's default DACL still names only the user's
|
||||
// ambient SIDs — none of the restricting SIDs. Every NEW object the
|
||||
// confined process creates (anonymous stdio pipes, sync objects) takes
|
||||
// its DACL from that default, so the write pass-2 check would deny
|
||||
// pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every
|
||||
// piped-stdio grandchild spawn. Merge a full-access ACE for a
|
||||
// restricting SID (the write SID under workspace-write, Everyone under
|
||||
// read-only): new-object creation stays gated by the parent object's
|
||||
// DACL, while the new object's own DACL passes pass-2.
|
||||
setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid)
|
||||
this.token = restricted
|
||||
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
|
||||
this.api = api
|
||||
} catch (error) {
|
||||
// Best-effort close on the failure path (last error already captured in `error`).
|
||||
api.closeHandle(currentToken)
|
||||
// Fail-closed cleanup: never leave a revocable (temp) grant or SID
|
||||
// allocation behind a failed init. Standing workspace ACEs are NOT
|
||||
// revoked — they are the intended end state (the reuse cache), not an
|
||||
// error artifact.
|
||||
const cleanupFailures: unknown[] = []
|
||||
const writeSidPtr = this.writeSidPtr
|
||||
if (writeSidPtr !== undefined) {
|
||||
for (const path of this.grantedPaths) {
|
||||
try {
|
||||
revokeWrite(api, path, writeSidPtr)
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const sidPtr of this.sidAllocations.splice(0)) {
|
||||
try {
|
||||
const freed = api.localFree(sidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation')
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...cleanupFailures],
|
||||
`AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a process under the restricted token. Fails closed: throws on every
|
||||
* Win32 failure; the child is never created unrestricted. With
|
||||
* `stdio: 'inherit'` the child shares the caller's stdio directly and is
|
||||
* placed in a kill-on-close job (dies with the caller). Call dispose() only
|
||||
* after all children have exited — revoking grants under a live child
|
||||
* removes its remaining write allowance.
|
||||
* @param options - the program, argv/cwd, and stdio shape.
|
||||
* @returns the running child.
|
||||
*/
|
||||
spawn(options: AclSandboxSpawnOptions): AclSandboxChild {
|
||||
const api = this.api
|
||||
const token = this.token
|
||||
if (api === undefined || token === undefined) throw new Error('AclSandbox is not initialized: call init() first')
|
||||
const args = options.args ?? []
|
||||
const cwd = options.cwd ?? process.cwd()
|
||||
|
||||
if (options.stdio === 'inherit') {
|
||||
const native = spawnSandboxedInherited(api, token, { command: options.command, args, cwd })
|
||||
let exitCodePromise: Promise<number> | undefined
|
||||
return {
|
||||
pid: native.pid,
|
||||
wait: async () => {
|
||||
exitCodePromise ??= Promise.resolve(waitForExit(api, native.process))
|
||||
const exitCode = await exitCodePromise
|
||||
if (api.closeHandle(native.job) === 0) throwLastError(api, 'CloseHandle', 'kill-on-close job')
|
||||
return { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const native = spawnSandboxed(api, token, { command: options.command, args, cwd })
|
||||
const stdout = drainPipe(api, native.stdoutRead)
|
||||
const stderr = drainPipe(api, native.stderrRead)
|
||||
// waitForExit is deliberately NOT started here: WaitForSingleObject blocks
|
||||
// the thread and would starve the drains while the child is still running
|
||||
// (pipe-buffer deadlock). The drains resolve only after the child closed
|
||||
// its pipe ends — by then the wait returns immediately.
|
||||
let exitCodePromise: Promise<number> | undefined
|
||||
return {
|
||||
pid: native.pid,
|
||||
wait: async () => {
|
||||
const stdoutBuffer = await stdout
|
||||
const stderrBuffer = await stderr
|
||||
exitCodePromise ??= Promise.resolve(waitForExit(api, native.process))
|
||||
return { stdout: stdoutBuffer, stderr: stderrBuffer, exitCode: await exitCodePromise }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the revocable (temp) grants, free the SID, close the token; the
|
||||
* standing workspace ACEs stay (the reuse cache). Reports every cleanup
|
||||
* failure.
|
||||
*/
|
||||
dispose(): void {
|
||||
const api = this.api
|
||||
if (api === undefined) return
|
||||
const failures: unknown[] = []
|
||||
const writeSidPtr = this.writeSidPtr
|
||||
if (writeSidPtr !== undefined) {
|
||||
if (this.manageDacls) {
|
||||
for (const path of this.grantedPaths) {
|
||||
try {
|
||||
revokeWrite(api, path, writeSidPtr)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const freed = api.localFree(writeSidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID')
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
const token = this.token
|
||||
if (token !== undefined) {
|
||||
try {
|
||||
if (api.closeHandle(token) === 0) throwLastError(api, 'CloseHandle', 'restricted token')
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
for (const sidPtr of this.sidAllocations.splice(0)) {
|
||||
try {
|
||||
const freed = api.localFree(sidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation')
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
this.api = undefined
|
||||
this.token = undefined
|
||||
this.writeSidPtr = undefined
|
||||
this.grantedPaths = []
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-sandbox-windows-acl`.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'sandbox-windows-acl-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or
|
||||
* mutable data relation beyond the fail-closed contracts it enforces at each
|
||||
* Win32 call boundary.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* The windows-acl confinement runner: the argv-prefix wrapper the sandbox
|
||||
* seam spawns in place of the caller's command. It creates the
|
||||
* WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the
|
||||
* wrapped argv under it with the CALLER'S stdio inherited (bytes flow
|
||||
* straight through), mirrors the child's exit code, and revokes its temp
|
||||
* grant on exit (workspace ACEs stay standing as the reuse cache).
|
||||
*
|
||||
* Stable argv contract (the seam builds it; a native-exe replacement would
|
||||
* keep the same contract):
|
||||
* [node, runner.js, '--workspace', <dir>, '--temp', <dir>,
|
||||
* '--mode', <read-only|workspace-write>,
|
||||
* ['--write-sid', <S-1-4-…>], '--', <argv...>]
|
||||
*
|
||||
* Modes:
|
||||
* - workspace-write: the workspace and temp directories carry the orphan-SID
|
||||
* Write grant; every other write is denied by the token intersection.
|
||||
* - read-only: STRICT zero grants — no directory is writable, not even the
|
||||
* NUL device (`> $null` fails with access denied); the restricting list
|
||||
* carries no orphan SID, so a standing grant ACE from an earlier
|
||||
* workspace-write period stays inert. BOTH modes drop Authenticated Users
|
||||
* (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the
|
||||
* Public tree writes are denied); the two lists share the keep-alive group
|
||||
* (logon SID, EVERYONE) and differ only by the orphan.
|
||||
*
|
||||
* `--write-sid`: the seam's grant contract — the CALLER has already
|
||||
* materialized the write-SID ACEs (the seam's workspace + private-temp
|
||||
* grants, server lifetime) and owns their revocation, so the runner neither
|
||||
* grants nor revokes (manageDacls: false). The carried SID is the
|
||||
* per-workspace identity ({@link workspaceWriteSid}) — the seam derives it
|
||||
* from the policy root; the flag's PRESENCE is the seam-managed marker (its
|
||||
* value must equal the workspace-derived SID). Absent `--write-sid`
|
||||
* (standalone/test use) the runner self-manages grants per invocation with
|
||||
* the same workspace-derived SID (its workspace ACEs are standing — the
|
||||
* reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in
|
||||
* workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN
|
||||
* environment (SetEnvironmentVariableW) to the `--temp` directory — a
|
||||
* PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs
|
||||
* /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment
|
||||
* NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in
|
||||
* CreateProcessAsUserW, verified empirically). Read-only leaves the ambient
|
||||
* temp entries untouched (writes there are denied anyway).
|
||||
*
|
||||
* Failure contract: every runner-side failure (bad args, missing
|
||||
* directories, token/grant/spawn errors) prints `windows-acl-run: <detail>`
|
||||
* to stderr and exits 127 — the seam's RUNNER_FAILURE_RULES matches that
|
||||
* signature. The child is NEVER spawned unrestricted.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/runner
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
|
||||
import { win32 } from './ffi.ts'
|
||||
import { AclSandbox } from './index.ts'
|
||||
import { workspaceWriteSid } from './workspace-sid.ts'
|
||||
|
||||
const RUNNER_SIGNATURE = 'windows-acl-run'
|
||||
const RUNNER_FAILURE_EXIT = 127
|
||||
|
||||
class RunnerFailure extends Error {}
|
||||
|
||||
/** Print the runner-failure signature line and unwind. */
|
||||
function fail(detail: string): never {
|
||||
process.stderr.write(`${RUNNER_SIGNATURE}: ${detail}\n`)
|
||||
throw new RunnerFailure(detail)
|
||||
}
|
||||
|
||||
interface ParsedArgs {
|
||||
workspace: string
|
||||
temp: string
|
||||
mode: 'read-only' | 'workspace-write'
|
||||
writeSid: string | undefined
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
function parseArgs(raw: string[]): ParsedArgs {
|
||||
let workspace: string | undefined
|
||||
let temp: string | undefined
|
||||
let mode: string | undefined
|
||||
let writeSid: string | undefined
|
||||
let index = 0
|
||||
for (; index < raw.length; index++) {
|
||||
const token = raw[index]
|
||||
if (token === '--') {
|
||||
index++
|
||||
break
|
||||
}
|
||||
index++
|
||||
const value = raw[index]
|
||||
if (value === undefined) fail(`missing value after ${token}`)
|
||||
switch (token) {
|
||||
case '--workspace': workspace = value; break
|
||||
case '--temp': temp = value; break
|
||||
case '--mode': mode = value; break
|
||||
case '--write-sid': writeSid = value; break
|
||||
default: fail(`unknown argument: ${token}`)
|
||||
}
|
||||
}
|
||||
if (workspace === undefined) fail('missing --workspace')
|
||||
if (temp === undefined) fail('missing --temp')
|
||||
if (mode !== 'read-only' && mode !== 'workspace-write') fail(`unknown mode: ${String(mode)}`)
|
||||
const argv = raw.slice(index)
|
||||
const command = argv[0]
|
||||
if (command === undefined) fail('missing command after --')
|
||||
return { workspace, temp, mode, writeSid, command, args: argv.slice(1) }
|
||||
}
|
||||
|
||||
function requireDirectory(label: string, path: string): void {
|
||||
if (!existsSync(path) || !statSync(path).isDirectory()) {
|
||||
fail(`${label} is not an existing directory: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const parsed = parseArgs(process.argv.slice(2))
|
||||
// Both directories are validated in both modes: a provider bug that passes
|
||||
// a bogus root must fail loudly at the runner boundary, never mid-child.
|
||||
requireDirectory('--workspace', parsed.workspace)
|
||||
requireDirectory('--temp', parsed.temp)
|
||||
|
||||
const api = await win32()
|
||||
// Ignore this process's own CTRL+C: the confined child (same console) keeps
|
||||
// handling its own; the runner must survive to revoke grants and mirror the
|
||||
// child's exit code.
|
||||
if (api.setConsoleCtrlHandler(null, 1) === 0) {
|
||||
fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
|
||||
// The write SID is the per-workspace identity in BOTH flows; the flag's
|
||||
// presence (seam-derived, or the self-managed derivation) selects who
|
||||
// owns the DACLs below.
|
||||
const writeSid = parsed.mode === 'workspace-write' ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : undefined
|
||||
const sandbox = new AclSandbox({
|
||||
writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [],
|
||||
tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null,
|
||||
mode: parsed.mode,
|
||||
...writeSid === undefined ? {} : { writeSid },
|
||||
// With --write-sid the seam owns the DACLs (workspace + private-temp
|
||||
// grants): this invocation must neither add nor revoke ACEs.
|
||||
manageDacls: parsed.writeSid === undefined,
|
||||
})
|
||||
await sandbox.init()
|
||||
|
||||
// The seam's per-session temp contract: under --write-sid, workspace-write
|
||||
// children see the PRIVATE per-session temp subdirectory through TMP/TEMP
|
||||
// (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment
|
||||
// (SetEnvironmentVariableW) and the child inherits the block; self-managed
|
||||
// and read-only runs keep the ambient entries.
|
||||
if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) {
|
||||
if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) {
|
||||
fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) {
|
||||
fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const child = sandbox.spawn({
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
const result = await child.wait()
|
||||
return result.exitCode
|
||||
} finally {
|
||||
// Cleanup failures must not mask the child's exit code: report and keep going.
|
||||
try {
|
||||
sandbox.dispose()
|
||||
} catch (error) {
|
||||
process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().then(
|
||||
(exitCode) => {
|
||||
// Exit-code mirroring is full-width on Windows, verified empirically on
|
||||
// this machine (Windows 11 build 26200, Node 24): a child that exits
|
||||
// with the NTSTATUS 0xC0000005 (STATUS_ACCESS_VIOLATION) is read back
|
||||
// by GetExitCodeProcess as the uint32 3221225477, and after
|
||||
// process.exitCode = 3221225477 the parent observes exactly
|
||||
// 3221225477 (spawnSync status). PowerShell's $LASTEXITCODE and cmd
|
||||
// print the signed view (-1073741819), but no truncation or masking
|
||||
// happens anywhere in the chain — the mirror contract holds for the
|
||||
// full 32-bit range, so no re-mapping is needed.
|
||||
process.exitCode = exitCode
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!(error instanceof RunnerFailure)) {
|
||||
process.stderr.write(`${RUNNER_SIGNATURE}: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
}
|
||||
process.exitCode = RUNNER_FAILURE_EXIT
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with
|
||||
* STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then
|
||||
* asynchronous pipe draining and exit waiting. Console isolation
|
||||
* (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this
|
||||
* restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED
|
||||
* (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is
|
||||
* pipe-based and unaffected; the child shares the host console.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/spawn
|
||||
*/
|
||||
|
||||
import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
/**
|
||||
* Quote one argument per the CommandLineToArgvW parsing rules: backslashes
|
||||
* are doubled only before a quote character — including the closing quote
|
||||
* this function appends, so a trailing backslash run is doubled as well
|
||||
* (otherwise an odd run would escape the closing quote into a literal
|
||||
* character and corrupt the rest of the command line). Mirrors the CRT
|
||||
* ArgvQuote behavior Microsoft documents for command-line arguments.
|
||||
* @param argument - one argv entry to quote.
|
||||
* @returns the quoted entry (bare when quoting is unnecessary).
|
||||
*/
|
||||
export function quoteArg(argument: string): string {
|
||||
if (argument === '') return '""'
|
||||
if (!/[\s"]/u.test(argument)) return argument
|
||||
let quoted = '"'
|
||||
for (let index = 0; index < argument.length; index++) {
|
||||
let backslashes = 0
|
||||
while (index < argument.length && argument.charAt(index) === '\\') {
|
||||
backslashes++
|
||||
index++
|
||||
}
|
||||
if (index === argument.length) {
|
||||
// Trailing backslash run: doubled so it cannot escape the closing quote.
|
||||
quoted += '\\'.repeat(backslashes * 2)
|
||||
} else if (argument.charAt(index) === '"') {
|
||||
quoted += '\\'.repeat(backslashes * 2 + 1) + '"'
|
||||
} else {
|
||||
quoted += '\\'.repeat(backslashes) + argument.charAt(index)
|
||||
}
|
||||
}
|
||||
return quoted + '"'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single command line CreateProcess parses from program + argv.
|
||||
* @param program - the executable (argv[0]).
|
||||
* @param args - the remaining argv entries.
|
||||
* @returns the joined, quoted command line.
|
||||
*/
|
||||
export function buildCommandLine(program: string, args: readonly string[]): string {
|
||||
return [program, ...args].map(quoteArg).join(' ')
|
||||
}
|
||||
|
||||
interface PipePair {
|
||||
read: NativePtr
|
||||
write: NativePtr
|
||||
}
|
||||
|
||||
function createPipe(api: Win32Bindings): PipePair {
|
||||
const readSlot = allocPtrSlot()
|
||||
const writeSlot = allocPtrSlot()
|
||||
if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, 'CreatePipe')
|
||||
const read = decodePtr(readSlot)
|
||||
const write = decodePtr(writeSlot)
|
||||
if (read === null || write === null) throwLastError(api, 'CreatePipe', 'null pipe handle')
|
||||
return { read, write }
|
||||
}
|
||||
|
||||
function setInheritable(api: Win32Bindings, handle: NativePtr, label: string): void {
|
||||
if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) {
|
||||
throwLastError(api, 'SetHandleInformation', label)
|
||||
}
|
||||
}
|
||||
|
||||
/** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */
|
||||
export interface SpawnedNative {
|
||||
pid: number
|
||||
process: NativePtr
|
||||
stdoutRead: NativePtr
|
||||
stderrRead: NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a process under the restricted token with piped stdio. The child's
|
||||
* stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends
|
||||
* are returned for draining. The child inherits the caller's environment block
|
||||
* (lpEnvironment NULL); the caller rewrites entries through
|
||||
* SetEnvironmentVariableW before spawning (the runner's per-session temp
|
||||
* contract) — passing an explicit block through koffi trips
|
||||
* ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically).
|
||||
* @param api - the binding table.
|
||||
* @param token - the restricted token the child runs under.
|
||||
* @param options - command, args, and working directory.
|
||||
* @returns the spawned child's handles.
|
||||
*/
|
||||
export function spawnSandboxed(
|
||||
api: Win32Bindings,
|
||||
token: NativePtr,
|
||||
options: { command: string; args: readonly string[]; cwd: string },
|
||||
): SpawnedNative {
|
||||
const stdIn = createPipe(api)
|
||||
const stdOut = createPipe(api)
|
||||
const stdErr = createPipe(api)
|
||||
// Child side of each pipe must be inheritable (POC lines 262-268).
|
||||
setInheritable(api, stdIn.read, 'stdin read end')
|
||||
setInheritable(api, stdOut.write, 'stdout write end')
|
||||
setInheritable(api, stdErr.write, 'stderr write end')
|
||||
|
||||
const startupInfo = allocStartupInfo()
|
||||
encodeStartupInfo(startupInfo, {
|
||||
cb: abi.STARTUPINFOW_SIZE,
|
||||
dwFlags: abi.STARTF_USESTDHANDLES,
|
||||
hStdInput: stdIn.read,
|
||||
hStdOutput: stdOut.write,
|
||||
hStdError: stdErr.write,
|
||||
})
|
||||
|
||||
const processInfo = allocProcessInfo()
|
||||
const commandLine = buildCommandLine(options.command, options.args)
|
||||
const created = api.createProcessAsUserW(
|
||||
token, null, commandLine,
|
||||
null, null,
|
||||
1, // bInheritHandles: required for redirection
|
||||
0, // no creation flags: suspended/no-window variants are unusable under the restriction
|
||||
null, options.cwd,
|
||||
startupInfo, processInfo,
|
||||
)
|
||||
// Capture the failure before CloseHandle calls clobber GetLastError, then
|
||||
// close every pipe handle created so far — the six-close contract this test
|
||||
// surface pins (tests/failure-paths.spec.ts).
|
||||
if (created === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(stdIn.read)
|
||||
api.closeHandle(stdIn.write)
|
||||
api.closeHandle(stdOut.read)
|
||||
api.closeHandle(stdOut.write)
|
||||
api.closeHandle(stdErr.read)
|
||||
api.closeHandle(stdErr.write)
|
||||
throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`)
|
||||
}
|
||||
|
||||
const info = decodeProcessInfo(processInfo)
|
||||
const processHandle = info.hProcess
|
||||
const threadHandle = info.hThread
|
||||
if (processHandle === null || threadHandle === null) {
|
||||
throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`)
|
||||
}
|
||||
|
||||
// Host-side cleanup: child handles are now duplicated in the child; the
|
||||
// host closes its copies so ReadFile sees EOF when the child exits.
|
||||
api.closeHandle(stdIn.read)
|
||||
api.closeHandle(stdOut.write)
|
||||
api.closeHandle(stdErr.write)
|
||||
api.closeHandle(stdIn.write)
|
||||
api.closeHandle(threadHandle)
|
||||
|
||||
return {
|
||||
pid: info.dwProcessId,
|
||||
process: processHandle,
|
||||
stdoutRead: stdOut.read,
|
||||
stderrRead: stdErr.read,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling.
|
||||
* @param api - the binding table.
|
||||
* @param handle - the pipe read end to drain (closed when done).
|
||||
* @returns the complete pipe contents.
|
||||
*/
|
||||
export async function drainPipe(api: Win32Bindings, handle: NativePtr): Promise<Buffer> {
|
||||
const chunks: Buffer[] = []
|
||||
for (;;) {
|
||||
const bytesReadSlot = allocUint32()
|
||||
const totalAvailSlot = allocUint32()
|
||||
const leftThisMessageSlot = allocUint32()
|
||||
const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot)
|
||||
if (peeked === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA) break // child closed its end: clean EOF
|
||||
throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`)
|
||||
}
|
||||
const available = decodeUint32(totalAvailSlot)
|
||||
if (available > 0) {
|
||||
const chunk = Buffer.alloc(available)
|
||||
const readSlot = allocUint32()
|
||||
if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) {
|
||||
throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`)
|
||||
}
|
||||
chunks.push(chunk.subarray(0, decodeUint32(readSlot)))
|
||||
}
|
||||
// Small backoff instead of setImmediate: a bare next-tick would busy-poll
|
||||
// the pipe at full event-loop speed while the child produces no output.
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 1))
|
||||
}
|
||||
api.closeHandle(handle)
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for process exit and return its exit code. Call only after both drains
|
||||
* have resolved — the drains finish when the child closed its pipe ends, i.e.
|
||||
* the child has already exited, so this wait returns immediately. Calling it
|
||||
* earlier would block the event loop and starve the drains (the pipe-buffer
|
||||
* deadlock the POC comments warn about).
|
||||
* @param api - the binding table.
|
||||
* @param process - the child process handle (closed when done).
|
||||
* @returns the child's exit code.
|
||||
*/
|
||||
export function waitForExit(api: Win32Bindings, process: NativePtr): number {
|
||||
const waitResult = api.waitForSingleObject(process, abi.INFINITE)
|
||||
if (waitResult === 0xFFFFFFFF) throwLastError(api, 'WaitForSingleObject')
|
||||
const exitCodeSlot = allocUint32()
|
||||
if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, 'GetExitCodeProcess')
|
||||
api.closeHandle(process)
|
||||
return decodeUint32(exitCodeSlot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at
|
||||
* LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout
|
||||
* verified by abi-probe.cpp). When the caller dies with the job handle open,
|
||||
* Windows terminates every process in the job — the orphan-child backstop.
|
||||
* The caller keeps the returned handle open for the child's lifetime.
|
||||
*/
|
||||
function createKillOnCloseJob(api: Win32Bindings): NativePtr {
|
||||
const job = api.createJobObjectW(null, null)
|
||||
if (isNullPtr(job)) throwLastError(api, 'CreateJobObjectW')
|
||||
const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE)
|
||||
information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET)
|
||||
if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(job)
|
||||
throwWin32(api, 'SetInformationJobObject', win32Code)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
/** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */
|
||||
export interface SpawnedInherited {
|
||||
pid: number
|
||||
process: NativePtr
|
||||
/** Kill-on-close job the child was placed in; caller closes it after the child exits. */
|
||||
job: NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a process under the restricted token whose stdio passes straight
|
||||
* through to the caller's pipes. This is the runner shape: the harness spawns
|
||||
* the runner with piped stdio, and the runner's confined child writes to
|
||||
* those same pipes.
|
||||
*
|
||||
* Node clears the inheritability of its stdio handles at startup
|
||||
* (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit
|
||||
* bit around the call (libuv instead duplicates the handles; re-enabling is
|
||||
* equivalent here and cheaper) and pass them explicitly via
|
||||
* STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles
|
||||
* ("The handle is invalid", verified the hard way). The child starts
|
||||
* suspended so it can be assigned to a kill-on-close job before it runs.
|
||||
* @param api - the binding table.
|
||||
* @param token - the restricted token the child runs under.
|
||||
* @param options - command, args, and working directory.
|
||||
* @returns the spawned child's handles and job.
|
||||
*/
|
||||
export function spawnSandboxedInherited(
|
||||
api: Win32Bindings,
|
||||
token: NativePtr,
|
||||
options: { command: string; args: readonly string[]; cwd: string },
|
||||
): SpawnedInherited {
|
||||
const job = createKillOnCloseJob(api)
|
||||
const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE)
|
||||
const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE)
|
||||
const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE)
|
||||
if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) {
|
||||
api.closeHandle(job)
|
||||
throwLastError(api, 'GetStdHandle', 'null standard handle')
|
||||
}
|
||||
|
||||
const makeInheritable = (handle: NativePtr, label: string): void => {
|
||||
if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) {
|
||||
throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`)
|
||||
}
|
||||
}
|
||||
const restoreInherit = (handle: NativePtr): void => {
|
||||
// Best-effort hygiene: the runner spawns nothing else; failures here must
|
||||
// not mask the child outcome, so the result is deliberately unchecked.
|
||||
api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0)
|
||||
}
|
||||
makeInheritable(stdIn, 'stdin')
|
||||
makeInheritable(stdOut, 'stdout')
|
||||
makeInheritable(stdErr, 'stderr')
|
||||
|
||||
const startupInfo = allocStartupInfo()
|
||||
encodeStartupInfo(startupInfo, {
|
||||
cb: abi.STARTUPINFOW_SIZE,
|
||||
dwFlags: abi.STARTF_USESTDHANDLES,
|
||||
hStdInput: stdIn,
|
||||
hStdOutput: stdOut,
|
||||
hStdError: stdErr,
|
||||
})
|
||||
|
||||
const processInfo = allocProcessInfo()
|
||||
const commandLine = buildCommandLine(options.command, options.args)
|
||||
const created = api.createProcessAsUserW(
|
||||
token, null, commandLine,
|
||||
null, null,
|
||||
1, // bInheritHandles: the re-enabled std handles must be inheritable
|
||||
abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution
|
||||
null, options.cwd,
|
||||
startupInfo, processInfo,
|
||||
)
|
||||
restoreInherit(stdIn)
|
||||
restoreInherit(stdOut)
|
||||
restoreInherit(stdErr)
|
||||
if (created === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(job)
|
||||
throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`)
|
||||
}
|
||||
|
||||
const info = decodeProcessInfo(processInfo)
|
||||
const processHandle = info.hProcess
|
||||
const threadHandle = info.hThread
|
||||
if (processHandle === null || threadHandle === null) {
|
||||
api.closeHandle(job)
|
||||
throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`)
|
||||
}
|
||||
|
||||
if (api.assignProcessToJobObject(job, processHandle) === 0) {
|
||||
// The child was created suspended and is NOT in the kill-on-close job:
|
||||
// closing handles would leave it suspended forever. Terminate it first,
|
||||
// then drop the handles and throw.
|
||||
const win32Code = api.getLastError()
|
||||
api.terminateProcess(processHandle, 1)
|
||||
api.closeHandle(threadHandle)
|
||||
api.closeHandle(processHandle)
|
||||
api.closeHandle(job)
|
||||
throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`)
|
||||
}
|
||||
if (api.resumeThread(threadHandle) === 0xFFFFFFFF) {
|
||||
// Closing the job triggers kill-on-close, so the suspended child dies
|
||||
// instead of hanging until this process exits; the process/thread handles
|
||||
// must go too.
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(threadHandle)
|
||||
api.closeHandle(processHandle)
|
||||
api.closeHandle(job)
|
||||
throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`)
|
||||
}
|
||||
api.closeHandle(threadHandle)
|
||||
|
||||
return { pid: info.dwProcessId, process: processHandle, job }
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Restricted-token construction: open the current process token, extract its
|
||||
* logon SID, build the well-known SIDs, and call CreateRestrictedToken with
|
||||
* the POC's restricting-SID allowlist. Every API call is checked; any failure
|
||||
* throws with the API name and the exact Win32 code — the original POC ignored
|
||||
* all of these and silently ran children with the FULL, unrestricted token.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/token
|
||||
*/
|
||||
|
||||
import { allocBytes, allocPtrSlot, allocUint32, decodePtr, decodePtrAt, decodeUint32, encodeUint32, isNullPtr, ptrAddress, throwLastError, throwWin32 } from './ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
import { buildExplicitAccess } from './acl.ts'
|
||||
import * as abi from './win32-abi.ts'
|
||||
|
||||
/**
|
||||
* Open the current process's access token with the rights
|
||||
* CreateRestrictedToken requires (the POC's OpenProcessToken call; the token
|
||||
* handle is obtained through a real OpenProcess handle because the
|
||||
* GetCurrentProcess() pseudo-handle is not addressable through koffi).
|
||||
* @param api - the binding table.
|
||||
* @returns the opened token handle.
|
||||
*/
|
||||
export function openCurrentProcessToken(api: Win32Bindings): NativePtr {
|
||||
const processHandle = api.openProcess(abi.PROCESS_QUERY_INFORMATION, 0, process.pid)
|
||||
if (isNullPtr(processHandle)) throwLastError(api, 'OpenProcess', `pid ${process.pid}`)
|
||||
|
||||
const tokenSlot = allocPtrSlot()
|
||||
const opened = api.openProcessToken(
|
||||
processHandle,
|
||||
abi.TOKEN_QUERY | abi.TOKEN_DUPLICATE | abi.TOKEN_ADJUST_DEFAULT | abi.TOKEN_ASSIGN_PRIMARY,
|
||||
tokenSlot,
|
||||
)
|
||||
if (opened === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.closeHandle(processHandle) // best-effort on the error path
|
||||
throwWin32(api, 'OpenProcessToken', win32Code, `pid ${process.pid}`)
|
||||
}
|
||||
if (api.closeHandle(processHandle) === 0) throwLastError(api, 'CloseHandle', 'OpenProcess process handle')
|
||||
const token = decodePtr(tokenSlot)
|
||||
if (token === null) throwWin32(api, 'OpenProcessToken', api.getLastError(), 'null token handle')
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and copy the token's logon session SID (S-1-5-5-x-y, attribute
|
||||
* SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and
|
||||
* other per-logon objects; the POC extracts it the same way.
|
||||
* @param api - the binding table.
|
||||
* @param token - the token whose groups are scanned.
|
||||
* @returns a copied logon SID (thrown when the token carries none).
|
||||
*/
|
||||
export function findLogonSid(api: Win32Bindings, token: NativePtr): NativePtr {
|
||||
const neededSlot = allocUint32()
|
||||
api.getTokenInformation(token, abi.TokenGroups, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER
|
||||
const needed = decodeUint32(neededSlot)
|
||||
if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenGroups size query')
|
||||
if (needed < abi.TOKEN_GROUPS_OFFSET) throwWin32(api, 'GetTokenInformation', api.getLastError(), `implausible TokenGroups size ${needed}`)
|
||||
|
||||
const groups = Buffer.alloc(needed)
|
||||
if (api.getTokenInformation(token, abi.TokenGroups, groups, groups.length, neededSlot) === 0) {
|
||||
throwLastError(api, 'GetTokenInformation', 'TokenGroups')
|
||||
}
|
||||
const groupCount = groups.readUInt32LE(0)
|
||||
for (let index = 0; index < groupCount; index++) {
|
||||
const sidPtr = decodePtrAt(groups, abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE)
|
||||
const attributes = groups.readUInt32LE(abi.TOKEN_GROUPS_OFFSET + index * abi.SID_AND_ATTRIBUTES_SIZE + 8)
|
||||
// >>> 0: JS bitwise & is signed 32-bit; SE_GROUP_LOGON_ID has bit 31 set.
|
||||
const isLogonId = ((attributes & abi.SE_GROUP_LOGON_ID) >>> 0) === (abi.SE_GROUP_LOGON_ID >>> 0)
|
||||
if (sidPtr === null || !isLogonId) continue
|
||||
const sidLength = api.getLengthSid(sidPtr)
|
||||
if (sidLength === 0) throwLastError(api, 'GetLengthSid', `logon SID group ${index}`)
|
||||
const copy = allocBytes(sidLength)
|
||||
if (api.copySid(sidLength, copy, sidPtr) === 0) throwLastError(api, 'CopySid', `logon SID group ${index}`)
|
||||
return copy
|
||||
}
|
||||
throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one well-known SID (68-byte buffer) and assert its validity.
|
||||
* @param api - the binding table.
|
||||
* @param type - the WELL_KNOWN_SID_TYPE to create.
|
||||
* @returns the created SID pointer.
|
||||
*/
|
||||
export function makeWellKnownSid(api: Win32Bindings, type: number): NativePtr {
|
||||
const sid = allocBytes(abi.SECURITY_MAX_SID_SIZE)
|
||||
const sizeSlot = allocUint32()
|
||||
encodeUint32(sizeSlot, abi.SECURITY_MAX_SID_SIZE)
|
||||
if (api.createWellKnownSid(type, null, sid, sizeSlot) === 0) {
|
||||
throwLastError(api, 'CreateWellKnownSid', `type ${type}`)
|
||||
}
|
||||
if (api.isValidSid(sid) === 0) throwLastError(api, 'IsValidSid', `CreateWellKnownSid type ${type}`)
|
||||
return sid
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one full-access allow ACE for `sidPtr` into the token's DEFAULT DACL
|
||||
* — the DACL every NEW object the token holder creates (without an explicit
|
||||
* security descriptor) takes. The restricted token inherits the user's
|
||||
* default DACL verbatim, which names no restricting SID: a new anonymous pipe
|
||||
* (child stdio) therefore fails the write pass-2 check at creation
|
||||
* (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every
|
||||
* piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the
|
||||
* write SID under workspace-write, Everyone under read-only), so each new
|
||||
* object's own DACL passes pass-2 while object creation itself stays gated by
|
||||
* the parent container's DACL (files outside the granted trees remain
|
||||
* uncreatable). Fails closed: any Win32 failure throws before the spawn.
|
||||
* @param api - the binding table.
|
||||
* @param token - the restricted token to adjust (requires TOKEN_ADJUST_DEFAULT).
|
||||
* @param sidPtr - the restricting SID whose full-access ACE joins the default DACL.
|
||||
*/
|
||||
export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, sidPtr: NativePtr): void {
|
||||
const neededSlot = allocUint32()
|
||||
api.getTokenInformation(token, abi.TokenDefaultDacl, null, 0, neededSlot) // expected to fail with ERROR_INSUFFICIENT_BUFFER
|
||||
const needed = decodeUint32(neededSlot)
|
||||
if (needed === 0) throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl size query')
|
||||
const buffer = Buffer.alloc(needed)
|
||||
if (api.getTokenInformation(token, abi.TokenDefaultDacl, buffer, buffer.length, neededSlot) === 0) {
|
||||
throwLastError(api, 'GetTokenInformation', 'TokenDefaultDacl')
|
||||
}
|
||||
const currentDacl = decodePtrAt(buffer, 0)
|
||||
if (currentDacl === null) {
|
||||
throw new Error('setTokenDefaultDaclGrant: the token carries no default DACL to extend')
|
||||
}
|
||||
const newDaclSlot = allocPtrSlot()
|
||||
const result = api.setEntriesInAclW(
|
||||
1,
|
||||
buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.FILE_ALL_ACCESS),
|
||||
currentDacl,
|
||||
newDaclSlot,
|
||||
)
|
||||
if (result !== abi.ERROR_SUCCESS) throwWin32(api, 'SetEntriesInAclW', result, 'default DACL merge')
|
||||
const newDacl = decodePtr(newDaclSlot)
|
||||
if (newDacl === null) throwWin32(api, 'SetEntriesInAclW', result, 'null merged default DACL')
|
||||
// TOKEN_DEFAULT_DACL { PACL DefaultDacl; } — the struct is exactly the
|
||||
// pointer; SetTokenInformation copies the ACL before returning.
|
||||
const info = Buffer.alloc(8)
|
||||
info.writeBigUInt64LE(newDacl, 0)
|
||||
if (api.setTokenInformation(token, abi.TokenDefaultDacl, info, info.length) === 0) {
|
||||
const win32Code = api.getLastError()
|
||||
api.localFree(newDacl)
|
||||
throwWin32(api, 'SetTokenInformation', win32Code, 'TokenDefaultDacl')
|
||||
}
|
||||
api.localFree(newDacl)
|
||||
}
|
||||
|
||||
/** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */
|
||||
function buildRestrictingSids(sids: readonly NativePtr[]): Buffer {
|
||||
const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length)
|
||||
sids.forEach((sid, index) => {
|
||||
buffer.writeBigUInt64LE(ptrAddress(sid), abi.SID_AND_ATTRIBUTES_SIZE * index)
|
||||
})
|
||||
return buffer
|
||||
}
|
||||
|
||||
/** The well-known SID packed into every restricted token's restricting list. */
|
||||
export interface RestrictingSidSet {
|
||||
world: NativePtr
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the write-restricted token with the mode-selected restricting list
|
||||
* (verified on Win11 26200, see the POC-worktree restrict-variant harness):
|
||||
* - read-only: [logon SID, EVERYONE]
|
||||
* - workspace-write: [logon SID, EVERYONE, orphan]
|
||||
*
|
||||
* The logon SID + EVERYONE keep-alive group is shared by both modes: early
|
||||
* DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee —
|
||||
* pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY
|
||||
* workspace-write — read-only carries no write SID, so a standing grant ACE
|
||||
* from an earlier workspace-write period (a `/permission` mode downgrade, or
|
||||
* a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED
|
||||
* pass-2 check grants only what the restricting list carries, keeping
|
||||
* read-only strictly zero-grant even with stale ACEs standing, while the
|
||||
* unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no
|
||||
* re-propagation). Authenticated Users is absent from BOTH lists: the WMI
|
||||
* namespace security check fails (0x80041003), so CIM is unavailable in
|
||||
* every confined mode, and the C:\-root tree-creation escape (standing
|
||||
* `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in
|
||||
* README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's
|
||||
* Public tree grants write to INTERACTIVE, so removing it closes that
|
||||
* escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts
|
||||
* for the verified failure modes. FAILS CLOSED: any failure throws — never
|
||||
* spawn unrestricted.
|
||||
* @param api - the binding table.
|
||||
* @param currentToken - the process token to restrict.
|
||||
* @param logonSid - the copied logon session SID.
|
||||
* @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only).
|
||||
* @param known - the well-known SIDs entering the restricting list.
|
||||
* @param mode - selects the restricting list (workspace-write adds the write SID).
|
||||
* @returns the restricted token handle.
|
||||
*/
|
||||
export function createRestrictedToken(
|
||||
api: Win32Bindings,
|
||||
currentToken: NativePtr,
|
||||
logonSid: NativePtr,
|
||||
writeSid: NativePtr | undefined,
|
||||
known: RestrictingSidSet,
|
||||
mode: 'read-only' | 'workspace-write',
|
||||
): NativePtr {
|
||||
const restrictingSids = buildRestrictingSids(mode === 'read-only'
|
||||
? [logonSid, known.world]
|
||||
: writeSid === undefined
|
||||
? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })()
|
||||
: [logonSid, known.world, writeSid])
|
||||
const tokenSlot = allocPtrSlot()
|
||||
const created = api.createRestrictedToken(
|
||||
currentToken,
|
||||
abi.DISABLE_MAX_PRIVILEGE | abi.LUA_TOKEN | abi.WRITE_RESTRICTED,
|
||||
0, null, // no SIDs disabled
|
||||
0, null, // no privileges deleted
|
||||
restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE,
|
||||
restrictingSids,
|
||||
tokenSlot,
|
||||
)
|
||||
if (created === 0) throwLastError(api, 'CreateRestrictedToken', `restricting SIDs: ${restrictingSids.length / abi.SID_AND_ATTRIBUTES_SIZE}`)
|
||||
const token = decodePtr(tokenSlot)
|
||||
if (token === null) throwWin32(api, 'CreateRestrictedToken', api.getLastError(), 'null token handle')
|
||||
return token
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Windows ABI constants for the ACL-sandbox backend.
|
||||
*
|
||||
* Every value was verified against the actual MinGW Windows headers on this
|
||||
* machine (C:\Strawberry\c\x86_64-w64-mingw32\include\) and cross-checked at
|
||||
* runtime by verify/abi-probe.cpp (same numbers; static_asserts passed).
|
||||
* Regenerate the probe with:
|
||||
* g++ -std=c++20 -municode -O2 -o abi-probe.exe abi-probe.cpp -ladvapi32 && .\abi-probe.exe
|
||||
*
|
||||
* The port intentionally excludes two pieces of the original POC
|
||||
* (github.com/huoyaoyuan/windows-acl-restrict-poc @ 10e4dfb), both verified
|
||||
* empirically on Windows 11 build 26200:
|
||||
* - S-1-2-1 (console logon SID) in the restricting list: the POC created it
|
||||
* via CreateWellKnownSid(WinLocalLogonSid) which fails here with
|
||||
* ERROR_INVALID_PARAMETER (87), leaving a garbage SID that makes
|
||||
* CreateRestrictedToken fail with ERROR_INVALID_SID (1337); using the
|
||||
* correct WinConsoleLogonSid does produce a valid S-1-2-1, but the child
|
||||
* then still dies with STATUS_DLL_INIT_FAILED (0xC0000142) whenever
|
||||
* CREATE_NO_WINDOW / CREATE_NEW_CONSOLE is used.
|
||||
* - Console isolation: under this restriction scheme a hidden console is not
|
||||
* attainable, so children share the host console (stdio redirection is
|
||||
* pipe-based and unaffected).
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/win32-abi
|
||||
*/
|
||||
|
||||
// ---- winnt.h ---------------------------------------------------------------
|
||||
|
||||
// TOKEN_* access rights (winnt.h lines ~3928)
|
||||
/** TOKEN_ASSIGN_PRIMARY: required to create a process with the token (CreateProcessAsUser). */
|
||||
export const TOKEN_ASSIGN_PRIMARY = 0x0001
|
||||
/** TOKEN_DUPLICATE: required to duplicate a token (DuplicateTokenEx). */
|
||||
export const TOKEN_DUPLICATE = 0x0002
|
||||
/** TOKEN_QUERY: required to read token information (GetTokenInformation). */
|
||||
export const TOKEN_QUERY = 0x0008
|
||||
/** TOKEN_ADJUST_DEFAULT: required to change a token's default DACL. */
|
||||
export const TOKEN_ADJUST_DEFAULT = 0x0080
|
||||
|
||||
// SID_AND_ATTRIBUTES.Attributes flags (winnt.h lines ~3446)
|
||||
/**
|
||||
* SE_GROUP_LOGON_ID: marks a token group SID as the logon SID (compared with
|
||||
* `>>> 0` — the flag's high bit makes it negative as a signed 32-bit number).
|
||||
*/
|
||||
export const SE_GROUP_LOGON_ID = 0xC0000000
|
||||
|
||||
// Generic file access (winnt.h lines ~5893-5913):
|
||||
// FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES
|
||||
// | FILE_WRITE_EA | FILE_APPEND_DATA | SYNCHRONIZE
|
||||
/** STANDARD_RIGHTS_WRITE (== READ_CONTROL): the standard-rights component of generic write access. */
|
||||
export const STANDARD_RIGHTS_WRITE = 0x00020000 // == READ_CONTROL
|
||||
/** FILE_GENERIC_WRITE: every file-write permission bit plus SYNCHRONIZE. */
|
||||
export const FILE_GENERIC_WRITE = 0x00120116
|
||||
/** DELETE: remove or rename the object (winnt.h line ~3009). */
|
||||
export const DELETE = 0x00010000
|
||||
/** FILE_DELETE_CHILD: remove or rename a directory's children (winnt.h line ~5907). */
|
||||
export const FILE_DELETE_CHILD = 0x0040
|
||||
// The POC granted FILE_GENERIC_WRITE minus READ_CONTROL, which displays as
|
||||
// "Write" in Explorer/icacls (windows-acl-restrict-poc.cpp line 16). The
|
||||
// sandbox grant adds DELETE and FILE_DELETE_CHILD so confined
|
||||
// delete/rename/git operations inside the granted trees pass the token's
|
||||
// access check too; Write+DELETE displays as "Modify" in icacls.
|
||||
// WRITE_DAC/WRITE_OWNER stay OUT deliberately — granting them would let the
|
||||
// child take ownership or rewrite DACLs and escape the allowlist (the
|
||||
// security boundary).
|
||||
/**
|
||||
* GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and
|
||||
* FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant
|
||||
* (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are
|
||||
* deliberately excluded: they would let the confined child take ownership or
|
||||
* rewrite DACLs.
|
||||
*/
|
||||
export const GRANT_MASK = (FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE // 0x00110156
|
||||
|
||||
/**
|
||||
* FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE
|
||||
* | 0x1FF): full file-object access. The mask of the ACE merged into the
|
||||
* restricted token's DEFAULT DACL — the token holder must keep full access to
|
||||
* every NEW object it creates (pipes included), and the ACE must name a
|
||||
* restricting SID so the write pass-2 check passes at creation.
|
||||
*/
|
||||
export const FILE_ALL_ACCESS = 0x1F01FF
|
||||
|
||||
// CreateRestrictedToken flags (winnt.h lines ~4284)
|
||||
/** DISABLE_MAX_PRIVILEGE: strip the token's maximum-privilege elevation so the confined child cannot escalate. */
|
||||
export const DISABLE_MAX_PRIVILEGE = 0x1
|
||||
/** LUA_TOKEN: produce a limited-user (filtered admin) token. */
|
||||
export const LUA_TOKEN = 0x4
|
||||
/** WRITE_RESTRICTED: intersect write access with the restricting SIDs' ACL grants — the sandbox's core mechanism. */
|
||||
export const WRITE_RESTRICTED = 0x8
|
||||
|
||||
// WELL_KNOWN_SID_TYPE (winnt.h lines ~3369-3407)
|
||||
/** WinWorldSid: S-1-1-0 (Everyone) — the only well-known SID the restricted tokens use (keep-alive group; see token.ts). */
|
||||
export const WinWorldSid = 1
|
||||
|
||||
// TOKEN_INFORMATION_CLASS (winnt.h line ~3963: TokenUser=1, TokenGroups=2)
|
||||
/** TokenGroups: GetTokenInformation class returning the token's group SIDs. */
|
||||
export const TokenGroups = 2
|
||||
/** TokenDefaultDacl: the token's default DACL — the DACL every NEW object created without an explicit SD takes. */
|
||||
export const TokenDefaultDacl = 6
|
||||
|
||||
// SECURITY_INFORMATION (winnt.h line ~4293)
|
||||
/** DACL_SECURITY_INFORMATION: read/write only the DACL of a security descriptor. */
|
||||
export const DACL_SECURITY_INFORMATION = 0x00000004
|
||||
|
||||
// PROCESS access rights (winnt.h lines ~4364)
|
||||
/** PROCESS_QUERY_INFORMATION: read exit status and times of a process handle. */
|
||||
export const PROCESS_QUERY_INFORMATION = 0x0400
|
||||
|
||||
// ---- accctrl.h -------------------------------------------------------------
|
||||
|
||||
// SE_OBJECT_TYPE (accctrl.h line ~22: SE_UNKNOWN_OBJECT_TYPE=0, SE_FILE_OBJECT=1)
|
||||
/** SE_FILE_OBJECT: the trustee path names a filesystem object. */
|
||||
export const SE_FILE_OBJECT = 1
|
||||
|
||||
// TRUSTEE_FORM / TRUSTEE_TYPE (accctrl.h lines ~38-55): both enums start at 0
|
||||
/** TRUSTEE_IS_UNKNOWN: TRUSTEE_TYPE unknown (TrusteeForm carries the shape). */
|
||||
export const TRUSTEE_IS_UNKNOWN = 0
|
||||
/** TRUSTEE_IS_SID: TRUSTEE_FORM — Trustee.ptstrName is a SID pointer. */
|
||||
export const TRUSTEE_IS_SID = 0
|
||||
/** NO_MULTIPLE_TRUSTEE: Trustee.pMultipleTrustee is null. */
|
||||
export const NO_MULTIPLE_TRUSTEE = 0
|
||||
|
||||
// ACCESS_MODE (accctrl.h line ~127: NOT_USED_ACCESS=0, GRANT_ACCESS=1, REVOKE_ACCESS=4)
|
||||
/** GRANT_ACCESS: SetEntriesInAclW adds the entry as an allow ACE. */
|
||||
export const GRANT_ACCESS = 1
|
||||
/** REVOKE_ACCESS: SetEntriesInAclW removes the matching allow ACE. */
|
||||
export const REVOKE_ACCESS = 4
|
||||
|
||||
// grfInheritance (accctrl.h lines ~137-142)
|
||||
/**
|
||||
* SUB_CONTAINERS_AND_OBJECTS_INHERIT: the ACE applies to the directory, its
|
||||
* subdirectories, and files (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE).
|
||||
*/
|
||||
export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 // == OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE
|
||||
|
||||
// ---- winbase.h -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* STARTF_USESTDHANDLES: STARTUPINFOW dwFlags — the child uses the hStd*
|
||||
* handles, required because Node clears stdio inheritability at startup.
|
||||
*/
|
||||
export const STARTF_USESTDHANDLES = 0x00000100
|
||||
/** HANDLE_FLAG_INHERIT: SetHandleInformation flag re-enabling handle inheritance for the spawned child's stdio handles. */
|
||||
export const HANDLE_FLAG_INHERIT = 0x1
|
||||
/** INFINITE: never-timeout wait value. */
|
||||
export const INFINITE = 0xFFFFFFFF
|
||||
/** MAX_PATH: legacy path length bound. */
|
||||
export const MAX_PATH = 260
|
||||
// winbase.h line ~410: the confined child starts suspended so the runner can
|
||||
// assign it to the kill-on-close job before any of its code runs.
|
||||
/** CREATE_SUSPENDED: create the child with its primary thread suspended until ResumeThread. */
|
||||
export const CREATE_SUSPENDED = 0x4
|
||||
// winbase.h lines ~497-499: GetStdHandle selectors.
|
||||
/** STD_INPUT_HANDLE: GetStdHandle selector for the standard input. */
|
||||
export const STD_INPUT_HANDLE = -10
|
||||
/** STD_OUTPUT_HANDLE: GetStdHandle selector for the standard output. */
|
||||
export const STD_OUTPUT_HANDLE = -11
|
||||
/** STD_ERROR_HANDLE: GetStdHandle selector for the standard error. */
|
||||
export const STD_ERROR_HANDLE = -12
|
||||
|
||||
// FormatMessageW flags (winbase.h lines ~1446-1469)
|
||||
/** FORMAT_MESSAGE_FROM_SYSTEM: format the message from the system message table. */
|
||||
export const FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000
|
||||
/** FORMAT_MESSAGE_IGNORE_INSERTS: skip insert-sequence substitution. */
|
||||
export const FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200
|
||||
|
||||
// ---- error codes -----------------------------------------------------------
|
||||
|
||||
/** ERROR_SUCCESS: the operation succeeded. */
|
||||
export const ERROR_SUCCESS = 0
|
||||
/** ERROR_INSUFFICIENT_BUFFER: a size-probe call succeeded but needs a larger buffer. */
|
||||
export const ERROR_INSUFFICIENT_BUFFER = 122
|
||||
/** ERROR_BROKEN_PIPE: the pipe's other end has closed. */
|
||||
export const ERROR_BROKEN_PIPE = 109
|
||||
/** ERROR_NO_DATA: the pipe is being closed. */
|
||||
export const ERROR_NO_DATA = 232
|
||||
/** ERROR_LOCK_VIOLATION: a byte-range lock conflicts with an existing lock (winerror.h line ~78). */
|
||||
export const ERROR_LOCK_VIOLATION = 33
|
||||
|
||||
// ---- lock files (fileapi.h / minwinbase.h / winnt.h) -----------------------
|
||||
|
||||
// CreateFileW dwDesiredAccess for the ACL lock files: plain read+write is
|
||||
// enough to take byte-range locks.
|
||||
/** GENERIC_READ: generic read access (winnt.h line ~3028). */
|
||||
export const GENERIC_READ = 0x80000000
|
||||
/** GENERIC_WRITE: generic write access (winnt.h line ~3029). */
|
||||
export const GENERIC_WRITE = 0x40000000
|
||||
// CreateFileW dwShareMode: the lock file is shared for read/write but NOT
|
||||
// for delete — if a locked file could be deleted and recreated underneath the
|
||||
// lock holder, two processes could hold "the same" lock on different files.
|
||||
/** FILE_SHARE_READ: other opens may read (winnt.h line ~5949). */
|
||||
export const FILE_SHARE_READ = 0x00000001
|
||||
/** FILE_SHARE_WRITE: other opens may write (winnt.h line ~5950). */
|
||||
export const FILE_SHARE_WRITE = 0x00000002
|
||||
/** FILE_SHARE_DELETE: other opens may delete (winnt.h line ~5951) — deliberately NOT used for lock files. */
|
||||
export const FILE_SHARE_DELETE = 0x00000004
|
||||
/** OPEN_ALWAYS: create the lock file if absent, open it otherwise (fileapi.h line ~21). */
|
||||
export const OPEN_ALWAYS = 4
|
||||
// LockFileEx dwFlags (minwinbase.h lines ~180-181, included by winbase.h).
|
||||
/** LOCKFILE_EXCLUSIVE_LOCK: request an exclusive byte-range lock. */
|
||||
export const LOCKFILE_EXCLUSIVE_LOCK = 0x2
|
||||
/** LOCKFILE_FAIL_IMMEDIATELY: fail with ERROR_LOCK_VIOLATION instead of waiting. */
|
||||
export const LOCKFILE_FAIL_IMMEDIATELY = 0x1
|
||||
|
||||
// ACE_HEADER.AceType (winnt.h lines ~3449-3463)
|
||||
/** ACCESS_ALLOWED_ACE_TYPE: an access-allowed ACE granting the mask to the trustee. */
|
||||
export const ACCESS_ALLOWED_ACE_TYPE = 0
|
||||
|
||||
// SID structure (winnt.h line ~280 SID_IDENTIFIER_AUTHORITY; line ~286
|
||||
// #define SID_MAX_SUB_AUTHORITIES 15).
|
||||
/** SID_MAX_SUB_AUTHORITIES: the most subauthorities a SID may carry. */
|
||||
export const SID_MAX_SUB_AUTHORITIES = 15
|
||||
|
||||
// ACE_HEADER.AceFlags (winnt.h lines ~3477-3524): inherited ACEs shown when
|
||||
// reading a DACL are marked with this bit and are not part of the explicit
|
||||
// DACL edits this module makes.
|
||||
/** INHERITED_ACE: the ACE was inherited from the parent object, not stored explicitly. */
|
||||
export const INHERITED_ACE = 0x10
|
||||
|
||||
// ---- job object (winnt.h lines ~4859-4866, ~5138, ~5190-5199) --------------
|
||||
|
||||
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last
|
||||
// job handle closes — the orphan-child backstop for the runner design.
|
||||
/** JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last job handle closes — the orphan-child backstop. */
|
||||
export const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
||||
// JOBOBJECTINFOCLASS: JobObjectBasicAccountingInformation=1, ..., ExtendedLimit=9.
|
||||
/** JobObjectExtendedLimitInformation: JOBOBJECTINFOCLASS for the extended limit structure. */
|
||||
export const JobObjectExtendedLimitInformation = 9
|
||||
// sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe.
|
||||
/** sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION), verified by abi-probe. */
|
||||
export const JOBOBJECT_EXTENDED_LIMIT_SIZE = 144
|
||||
// LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
// (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 + PerJobUserTimeLimit@8),
|
||||
// verified by abi-probe.
|
||||
/**
|
||||
* LimitFlags offset inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
* (BasicLimitInformation@0 + PerProcessUserTimeLimit@0 +
|
||||
* PerJobUserTimeLimit@8), verified by abi-probe.
|
||||
*/
|
||||
export const JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET = 16
|
||||
|
||||
// ---- ABI layout, verified by verify/abi-probe.cpp (x64) --------------------
|
||||
|
||||
/** SECURITY_MAX_SID_SIZE: maximum SID byte size. */
|
||||
export const SECURITY_MAX_SID_SIZE = 68
|
||||
/** SID_AND_ATTRIBUTES stride: { PSID Sid @0 (8); DWORD Attributes @8 (4) } + pad. */
|
||||
export const SID_AND_ATTRIBUTES_SIZE = 16
|
||||
/** TOKEN_GROUPS.Groups[] starts at offset 8 (GroupCount @0 + alignment). */
|
||||
export const TOKEN_GROUPS_OFFSET = 8
|
||||
/** sizeof(EXPLICIT_ACCESS_W): perms@0 mode@4 inheritance@8 Trustee@16. */
|
||||
export const EXPLICIT_ACCESS_W_SIZE = 48
|
||||
/** Trustee offset inside EXPLICIT_ACCESS_W. */
|
||||
export const TRUSTEE_W_OFFSET = 16
|
||||
/** ptstrName offset inside TRUSTEE_W (=> 40 inside EXPLICIT_ACCESS_W). */
|
||||
export const TRUSTEE_W_PTSTRNAME_OFFSET = 24
|
||||
/** sizeof(STARTUPINFOW), verified by abi-probe. */
|
||||
export const STARTUPINFOW_SIZE = 104
|
||||
/** sizeof(PROCESS_INFORMATION), verified by abi-probe. */
|
||||
export const PROCESS_INFORMATION_SIZE = 24
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived
|
||||
* from the canonical workspace path, whose ACEs form that workspace's write
|
||||
* allowlist. Every confined execution of the same workspace — across
|
||||
* sessions, server restarts, and calls — carries the SAME write SID, so the
|
||||
* workspace-root ACE materializes once per workspace per machine (the
|
||||
* grant's exact-ACE skip then makes every later provision O(1)) instead of
|
||||
* once per session. The SID's power is defined solely by the ACEs that name
|
||||
* it (which exist only on the workspace tree and the session's private temp
|
||||
* directory), and only tokens minted for that workspace carry it — the SID
|
||||
* string itself is not a secret (the previous per-session SID was likewise
|
||||
* logged in the plain).
|
||||
*
|
||||
* The input MUST be the canonical workspace path (`realpathSync.native` on
|
||||
* Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it):
|
||||
* canonicalization converges case/alias spellings, so two spellings of one
|
||||
* workspace derive one SID; an as-spelled fallback path would mint a second
|
||||
* identity for the same directory (self-healing, at the cost of one extra
|
||||
* tree propagation). Renaming the workspace directory derives a new SID —
|
||||
* the old standing ACEs are inert residue, and the next session re-propagates
|
||||
* once.
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit,
|
||||
* matching the orphan shape the token and ACE layers already carry).
|
||||
* @param workspaceRoot - the canonical workspace path.
|
||||
* @returns the SDDL string form.
|
||||
*/
|
||||
export function workspaceWriteSid(workspaceRoot: string): string {
|
||||
const digest = createHash('sha256').update(workspaceRoot, 'utf8').digest()
|
||||
const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1
|
||||
const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1
|
||||
return `S-1-4-${first}-${second}`
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* ACL edit tests: the read-merge-write grant keeps pre-existing explicit
|
||||
* ACEs, interleaved sandbox instances do not clobber each other, the
|
||||
* per-path lock primitive is deterministic, and the grant mask carries
|
||||
* DELETE + FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER).
|
||||
*
|
||||
* All state lives in %TEMP% mkdtemp scratch directories; the only exception
|
||||
* is the mandated lock infrastructure under <GetTempPathW()>\dsh-acl-locks,
|
||||
* whose per-test lock file is removed in cleanup.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts'
|
||||
import { AclSandbox } from '../src/index.ts'
|
||||
import { createRestrictedToken } from '../src/token.ts'
|
||||
import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import * as abi from '../src/win32-abi.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
|
||||
/** FILE_READ_DATA (winnt.h line ~5895): the harmless mask the explicit test ACE grants. */
|
||||
const FILE_READ_DATA = 0x0001
|
||||
|
||||
/** koffi SID layout: revision@0, subAuthorityCount@1, identifierAuthority@2 (6 bytes, big-endian), subAuthority@8. */
|
||||
const SID_STRUCT = koffi.struct('DSH_ACL_SPEC_SID', {
|
||||
revision: 'uint8',
|
||||
subAuthorityCount: 'uint8',
|
||||
identifierAuthority: 'uint8[6]',
|
||||
subAuthority: 'uint32[8]',
|
||||
})
|
||||
|
||||
interface SidLayout {
|
||||
revision: number
|
||||
subAuthorityCount: number
|
||||
identifierAuthority: number[]
|
||||
subAuthority: number[]
|
||||
}
|
||||
|
||||
/** One direct (explicit, non-inherited) allow ACE of a directory DACL. */
|
||||
interface DirectAce {
|
||||
sid: string
|
||||
mask: number
|
||||
}
|
||||
|
||||
/** Convert one SID string to a LocalAlloc'd SID pointer (caller frees). */
|
||||
function sidFromString(api: Win32Bindings, sid: string): NativePtr {
|
||||
const slot = allocPtrSlot()
|
||||
if (api.convertStringSidToSidW(sid, slot) === 0) throw new Error(`ConvertStringSidToSidW failed for ${sid}`)
|
||||
const ptr = decodePtr(slot)
|
||||
if (ptr === null) throw new Error(`ConvertStringSidToSidW returned null for ${sid}`)
|
||||
return ptr
|
||||
}
|
||||
|
||||
/** Stringify a decoded SID layout (identifierAuthority bytes 2..5 are the big-endian value). */
|
||||
function sidString(sid: SidLayout): string {
|
||||
const authority = ((sid.identifierAuthority[2] ?? 0) << 24)
|
||||
| ((sid.identifierAuthority[3] ?? 0) << 16)
|
||||
| ((sid.identifierAuthority[4] ?? 0) << 8)
|
||||
| (sid.identifierAuthority[5] ?? 0)
|
||||
const subs = sid.subAuthority.slice(0, sid.subAuthorityCount).join('-')
|
||||
return `S-${sid.revision}-${authority}${sid.subAuthorityCount > 0 ? `-${subs}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the directory's explicit allow ACEs (inherited ACEs excluded): each
|
||||
* ACE header is AceType@0, AceFlags@1, AceSize@2 (winnt.h lines ~3477-3480);
|
||||
* ACCESS_ALLOWED_ACE stores Mask@4 and the inline SID@8. The ACL pointer sits
|
||||
* inside the descriptor allocation — only the descriptor is LocalFree'd.
|
||||
*/
|
||||
function readDirectAces(api: Win32Bindings, path: string): DirectAce[] {
|
||||
const ownerSlot = allocPtrSlot()
|
||||
const groupSlot = allocPtrSlot()
|
||||
const daclSlot = allocPtrSlot()
|
||||
const saclSlot = allocPtrSlot()
|
||||
const descriptorSlot = allocPtrSlot()
|
||||
const readResult = api.getNamedSecurityInfoW(
|
||||
path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION,
|
||||
ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot,
|
||||
)
|
||||
if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW failed (${readResult}) for ${path}`)
|
||||
const acl = decodePtr(daclSlot)
|
||||
const descriptor = decodePtr(descriptorSlot)
|
||||
try {
|
||||
if (acl === null) return []
|
||||
const aclSize = koffi.decode(acl, 2, 'uint16') as number
|
||||
const aces: DirectAce[] = []
|
||||
for (let offset = 8; offset + 8 <= aclSize;) {
|
||||
const flags = koffi.decode(acl, offset + 1, 'uint8') as number
|
||||
const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number
|
||||
if ((flags & abi.INHERITED_ACE) === 0) {
|
||||
aces.push({ sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), mask: koffi.decode(acl, offset + 4, 'uint32') as number })
|
||||
}
|
||||
offset += aceSize
|
||||
}
|
||||
return aces
|
||||
} finally {
|
||||
if (descriptor !== null) api.localFree(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!isWin32)('ACL editing', () => {
|
||||
const scratchDirs: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function scratch(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-edit-'))
|
||||
scratchDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
it('grantWrite merges into the current DACL: an explicit Users ACE survives grant+revoke', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const usersSid = sidFromString(api, 'S-1-5-32-545')
|
||||
const orphanSid = sidFromString(api, 'S-1-4-4242-1')
|
||||
try {
|
||||
// Install one explicit ACE (Users + benign read mask) with the
|
||||
// package's own bindings, exactly like a pre-existing explicit DACL
|
||||
// entry another sandbox instance or administrator added.
|
||||
const newAclSlot = allocPtrSlot()
|
||||
const mergeResult = api.setEntriesInAclW(1, buildExplicitAccess(usersSid, abi.GRANT_ACCESS, FILE_READ_DATA), null, newAclSlot)
|
||||
expect(mergeResult, `SetEntriesInAclW setup (${mergeResult})`).toBe(abi.ERROR_SUCCESS)
|
||||
const newAcl = decodePtr(newAclSlot)
|
||||
expect(newAcl).not.toBeNull()
|
||||
const applyResult = api.setNamedSecurityInfoW(
|
||||
dir, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, null, null, newAcl, null,
|
||||
)
|
||||
const freed = newAcl === null ? null : api.localFree(newAcl)
|
||||
expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS)
|
||||
expect(isNullPtr(freed)).toBe(true)
|
||||
|
||||
grantWrite(api, dir, orphanSid)
|
||||
revokeWrite(api, dir, orphanSid)
|
||||
|
||||
const aces = readDirectAces(api, dir)
|
||||
expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved
|
||||
expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed
|
||||
} finally {
|
||||
if (!isNullPtr(usersSid)) api.localFree(usersSid)
|
||||
if (!isNullPtr(orphanSid)) api.localFree(orphanSid)
|
||||
}
|
||||
})
|
||||
|
||||
it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const orphanSid = sidFromString(api, 'S-1-4-4242-2')
|
||||
const apply = vi.spyOn(api, 'setNamedSecurityInfoW')
|
||||
try {
|
||||
grantWrite(api, dir, orphanSid)
|
||||
expect(apply).toHaveBeenCalledTimes(1)
|
||||
// The exact ACE now stands (the per-session grant surviving from a
|
||||
// previous server lifetime): the second grant is a DACL read only.
|
||||
grantWrite(api, dir, orphanSid)
|
||||
expect(apply).toHaveBeenCalledTimes(1)
|
||||
const aces = readDirectAces(api, dir)
|
||||
expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1)
|
||||
revokeWrite(api, dir, orphanSid)
|
||||
expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false)
|
||||
} finally {
|
||||
apply.mockRestore()
|
||||
if (!isNullPtr(orphanSid)) api.localFree(orphanSid)
|
||||
}
|
||||
})
|
||||
|
||||
it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' })
|
||||
const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })
|
||||
await sandboxA.init()
|
||||
await sandboxB.init()
|
||||
// Workspace ACEs are STANDING: dispose frees the instance's SID
|
||||
// allocations but deliberately leaves the ACEs — they are the reuse
|
||||
// cache the next provision's exact-ACE skip consumes.
|
||||
sandboxA.dispose()
|
||||
sandboxB.dispose()
|
||||
const aces = readDirectAces(api, dir)
|
||||
expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(true)
|
||||
expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true)
|
||||
})
|
||||
|
||||
it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => {
|
||||
const api = await win32()
|
||||
const workspaceDir = scratch()
|
||||
const tempDir = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
sandbox.dispose()
|
||||
const workspaceAces = readDirectAces(api, workspaceDir)
|
||||
expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true)
|
||||
const tempAces = readDirectAces(api, tempDir)
|
||||
expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false)
|
||||
})
|
||||
|
||||
it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => {
|
||||
const dir = scratch()
|
||||
expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }))
|
||||
.toThrow(/requires a write SID/)
|
||||
expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write'))
|
||||
.toThrow(/requires the write SID/)
|
||||
})
|
||||
|
||||
it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const lockPath = lockFilePath(api, dir)
|
||||
const open = (): NativePtr => api.createFileW(
|
||||
lockPath, abi.GENERIC_READ | abi.GENERIC_WRITE,
|
||||
abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, null, abi.OPEN_ALWAYS, 0, null,
|
||||
)
|
||||
const first = open()
|
||||
const second = open()
|
||||
expect(isInvalidHandle(first)).toBe(false)
|
||||
expect(isInvalidHandle(second)).toBe(false)
|
||||
try {
|
||||
expect(api.lockFileEx(first, abi.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, allocOverlapped())).toBe(1)
|
||||
expect(api.lockFileEx(second, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(0)
|
||||
expect(api.getLastError()).toBe(abi.ERROR_LOCK_VIOLATION)
|
||||
expect(api.unlockFileEx(first, 0, 1, 0, allocOverlapped())).toBe(1)
|
||||
expect(api.lockFileEx(second, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(1)
|
||||
expect(api.unlockFileEx(second, 0, 1, 0, allocOverlapped())).toBe(1)
|
||||
} finally {
|
||||
api.closeHandle(first)
|
||||
api.closeHandle(second)
|
||||
rmSync(lockPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('withPathLock serializes the action and releases the lock even when the action throws', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const lockPath = lockFilePath(api, dir)
|
||||
let attempts = 0
|
||||
expect(() => withPathLock(api, dir, () => {
|
||||
attempts++
|
||||
throw new Error('action failure')
|
||||
})).toThrow('action failure')
|
||||
expect(attempts).toBe(1)
|
||||
// The lock was released: a fresh immediate lock succeeds.
|
||||
const handle = api.createFileW(
|
||||
lockPath, abi.GENERIC_READ | abi.GENERIC_WRITE,
|
||||
abi.FILE_SHARE_READ | abi.FILE_SHARE_WRITE, null, abi.OPEN_ALWAYS, 0, null,
|
||||
)
|
||||
expect(isInvalidHandle(handle)).toBe(false)
|
||||
try {
|
||||
expect(api.lockFileEx(handle, abi.LOCKFILE_EXCLUSIVE_LOCK | abi.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, allocOverlapped())).toBe(1)
|
||||
expect(api.unlockFileEx(handle, 0, 1, 0, allocOverlapped())).toBe(1)
|
||||
} finally {
|
||||
api.closeHandle(handle)
|
||||
rmSync(lockPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('the applied grant mask carries DELETE and FILE_DELETE_CHILD (never WRITE_DAC/WRITE_OWNER)', async () => {
|
||||
const api = await win32()
|
||||
const dir = scratch()
|
||||
const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-1234-5', mode: 'workspace-write' })
|
||||
try {
|
||||
await sandbox.init()
|
||||
const grant = readDirectAces(api, dir).find(ace => ace.sid === 'S-1-4-1234-5')
|
||||
expect(grant).toBeDefined()
|
||||
const mask = grant?.mask ?? 0
|
||||
expect(mask).toBe(abi.GRANT_MASK)
|
||||
expect(mask & abi.DELETE).toBe(abi.DELETE)
|
||||
expect(mask & abi.FILE_DELETE_CHILD).toBe(abi.FILE_DELETE_CHILD)
|
||||
expect(mask & 0x00040000).toBe(0) // WRITE_DAC must never be granted
|
||||
expect(mask & 0x00080000).toBe(0) // WRITE_OWNER must never be granted
|
||||
} finally {
|
||||
sandbox.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Failure-path unit tests with minimal stub binding tables: the spawn
|
||||
* helpers must close every handle they created before throwing, and
|
||||
* getTempPath must refuse to decode a buffer GetTempPathW never wrote.
|
||||
* Pure stubs — no real Win32 calls, so these run on every platform.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import { PROCESS_INFORMATION, getTempPath } from '../src/ffi.ts'
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { Win32Error } from '../src/errors.ts'
|
||||
import { spawnSandboxed, spawnSandboxedInherited } from '../src/spawn.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
/** The stub the CreateProcessAsUserW failure branch needs: pipes "succeed", the spawn fails with Win32 5. */
|
||||
function pipeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } {
|
||||
const closed: bigint[] = []
|
||||
let next = 1n
|
||||
const closeHandle = vi.fn((handle: NativePtr) => {
|
||||
closed.push(handle)
|
||||
return 1
|
||||
})
|
||||
const api = {
|
||||
createPipe: vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => {
|
||||
koffi.encode(readSlot, PVOID, next++)
|
||||
koffi.encode(writeSlot, PVOID, next++)
|
||||
return 1
|
||||
}),
|
||||
setHandleInformation: vi.fn(() => 1),
|
||||
createProcessAsUserW: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 5), // ERROR_ACCESS_DENIED: the failure the branch reports
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return { api, closed, closeHandle }
|
||||
}
|
||||
|
||||
/** The stub the ResumeThread failure branch needs: everything succeeds until ResumeThread returns 0xFFFFFFFF. */
|
||||
function resumeFailureApi(): { api: Win32Bindings; closed: bigint[]; closeHandle: ReturnType<typeof vi.fn> } {
|
||||
const closed: bigint[] = []
|
||||
let std = 50n
|
||||
const closeHandle = vi.fn((handle: NativePtr) => {
|
||||
closed.push(handle)
|
||||
return 1
|
||||
})
|
||||
const api = {
|
||||
createJobObjectW: vi.fn(() => 100n),
|
||||
setInformationJobObject: vi.fn(() => 1),
|
||||
getStdHandle: vi.fn(() => std++),
|
||||
setHandleInformation: vi.fn(() => 1),
|
||||
createProcessAsUserW: vi.fn((
|
||||
_token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown,
|
||||
_inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr,
|
||||
) => {
|
||||
koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: 200n, hThread: 201n, dwProcessId: 1234, dwThreadId: 5678 })
|
||||
return 1
|
||||
}),
|
||||
assignProcessToJobObject: vi.fn(() => 1),
|
||||
resumeThread: vi.fn(() => 0xFFFFFFFF),
|
||||
getLastError: vi.fn(() => 5),
|
||||
closeHandle,
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return { api, closed, closeHandle }
|
||||
}
|
||||
|
||||
describe('spawn failure paths close their handles', () => {
|
||||
// A dummy token value; the stubbed spawn never reads it.
|
||||
const token = 1n as NativePtr
|
||||
|
||||
it('spawnSandboxed closes all six pipe handles before throwing when CreateProcessAsUserW fails', () => {
|
||||
const { api, closed, closeHandle } = pipeFailureApi()
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxed(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('CreateProcessAsUserW')
|
||||
expect((caught as Win32Error).win32Code).toBe(5)
|
||||
expect(closeHandle).toHaveBeenCalledTimes(6)
|
||||
expect(closed).toEqual([1n, 2n, 3n, 4n, 5n, 6n])
|
||||
})
|
||||
|
||||
it('spawnSandboxedInherited closes thread, process, and kill-on-close job before throwing when ResumeThread fails', () => {
|
||||
const { api, closed, closeHandle } = resumeFailureApi()
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('ResumeThread')
|
||||
expect((caught as Win32Error).win32Code).toBe(5)
|
||||
// thread, process, job — closing the job triggers kill-on-close so the
|
||||
// suspended child dies instead of hanging until this process exits.
|
||||
expect(closeHandle).toHaveBeenCalledTimes(3)
|
||||
expect(closed).toEqual([201n, 200n, 100n])
|
||||
})
|
||||
|
||||
it('spawnSandboxedInherited TERMINATES the suspended child before closing handles when AssignProcessToJobObject fails', () => {
|
||||
// The child is created suspended and is NOT in the kill-on-close job when
|
||||
// the assignment fails: closing the job cannot kill it, so the failure
|
||||
// branch must TerminateProcess first or every failure strands a hanging
|
||||
// orphan forever.
|
||||
const { api: baseApi, closeHandle } = resumeFailureApi()
|
||||
type JobFailureApi = Win32Bindings & {
|
||||
assignProcessToJobObject: ReturnType<typeof vi.fn>
|
||||
terminateProcess: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const api = baseApi as JobFailureApi
|
||||
api.assignProcessToJobObject = vi.fn(() => 0)
|
||||
api.terminateProcess = vi.fn(() => 1)
|
||||
let caught: unknown
|
||||
try {
|
||||
spawnSandboxedInherited(api, token, { command: 'probe.exe', args: [], cwd: 'C:\\' })
|
||||
} catch (error) {
|
||||
caught = error
|
||||
}
|
||||
expect(caught).toBeInstanceOf(Win32Error)
|
||||
expect((caught as Win32Error).api).toBe('AssignProcessToJobObject')
|
||||
expect(api.terminateProcess).toHaveBeenCalledExactlyOnceWith(200n, 1)
|
||||
// thread, process, job — and the child is already dead before they close.
|
||||
expect(closeHandle).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTempPath buffer defense', () => {
|
||||
it('throws a clear error instead of decoding a buffer GetTempPathW never wrote', () => {
|
||||
const api = { getTempPathW: vi.fn(() => 300) } as unknown as Win32Bindings // 300 > the 261-char buffer
|
||||
expect(() => getTempPath(api)).toThrow(/GetTempPathW failed \(Win32 122\): required 300/u)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* AclWriteGrant failure-path tests with stub binding tables (the
|
||||
* failure-paths.spec.ts pattern): create fails closed on SID-parse failure,
|
||||
* dispose aggregates revocation and SID-free failures into an
|
||||
* AggregateError. Pure stubs — no real Win32 calls, so these run on every
|
||||
* platform; the real-FFI round-trip lives in grant.spec.ts (win32 only).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import koffi from 'koffi'
|
||||
|
||||
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
|
||||
import { AclWriteGrant } from '../src/index.ts'
|
||||
|
||||
const PVOID = koffi.pointer('void')
|
||||
|
||||
/** The stub the grant-then-fail-revoke sequence needs: every call succeeds until the DACL read is flipped off. */
|
||||
function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } {
|
||||
const state = { failReads: false }
|
||||
const api = {
|
||||
convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, 42n)
|
||||
return 1
|
||||
}),
|
||||
getTempPathW: vi.fn((_length: number, buffer: Buffer) => {
|
||||
const temp = tmpdir().endsWith('/') || tmpdir().endsWith('\\') ? tmpdir() : `${tmpdir()}/`
|
||||
buffer.write(temp, 'utf16le')
|
||||
return temp.length
|
||||
}),
|
||||
createFileW: vi.fn(() => 7n),
|
||||
lockFileEx: vi.fn(() => 1),
|
||||
unlockFileEx: vi.fn(() => 1),
|
||||
closeHandle: vi.fn(() => 1),
|
||||
getNamedSecurityInfoW: vi.fn((
|
||||
_path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown,
|
||||
dacl: NativePtr, _sacl: unknown, descriptor: NativePtr,
|
||||
) => {
|
||||
if (state.failReads) return 2 // ERROR_FILE_NOT_FOUND — the revoke's read fails
|
||||
koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one
|
||||
koffi.encode(descriptor, PVOID, 0n)
|
||||
return 0
|
||||
}),
|
||||
setEntriesInAclW: vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => {
|
||||
koffi.encode(newAcl, PVOID, 9n)
|
||||
return 0
|
||||
}),
|
||||
setNamedSecurityInfoW: vi.fn(() => 0),
|
||||
localFree: vi.fn(() => 0n),
|
||||
getLastError: vi.fn(() => 2),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
return { api, failReads: () => { state.failReads = true } }
|
||||
}
|
||||
|
||||
describe('AclWriteGrant failure paths', () => {
|
||||
it('create fails closed: a SID parse failure throws before anything is granted', () => {
|
||||
const api = {
|
||||
convertStringSidToSidW: vi.fn(() => 0),
|
||||
getLastError: vi.fn(() => 87),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
expect(() => AclWriteGrant.create('S-1-4-abc-1', api)).toThrow(/ConvertStringSidToSidW/)
|
||||
})
|
||||
|
||||
it('create fails closed: a null SID pointer is rejected', () => {
|
||||
const api = {
|
||||
convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, 0n)
|
||||
return 1
|
||||
}),
|
||||
getLastError: vi.fn(() => 87),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/null SID/)
|
||||
})
|
||||
|
||||
it('dispose aggregates a failing revocation into an AggregateError (best-effort cleanup)', () => {
|
||||
const { api, failReads } = grantThenFailApi()
|
||||
const grant = AclWriteGrant.create('S-1-4-42-42', api)
|
||||
grant.add('C:\\granted')
|
||||
expect(grant.paths).toEqual(['C:\\granted'])
|
||||
failReads()
|
||||
expect(() =>{ grant.dispose() }).toThrow(AggregateError)
|
||||
})
|
||||
|
||||
it('dispose aggregates a failing SID free into an AggregateError', () => {
|
||||
const api = {
|
||||
convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => {
|
||||
koffi.encode(slot, PVOID, 42n)
|
||||
return 1
|
||||
}),
|
||||
localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed"
|
||||
getLastError: vi.fn(() => 87),
|
||||
formatMessageW: vi.fn(() => 0),
|
||||
} as unknown as Win32Bindings
|
||||
const grant = AclWriteGrant.create('S-1-4-42-42', api)
|
||||
expect(() =>{ grant.dispose() }).toThrow(AggregateError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* AclWriteGrant tests: the server-side grant materialization — SID parsing
|
||||
* fail-closed, ACE add/dispose round-trip against the REAL directory DACL
|
||||
* (observed through icacls, the operator's own tool), the recorded path
|
||||
* order, and the standing/revocable lifecycle split (workspace ACEs outlive
|
||||
* dispose as the reuse cache; temp ACEs revoke). Win32-only, like the other
|
||||
* real-FFI suites.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { AclWriteGrant } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
|
||||
/** The directory DACL as icacls renders it (the operator-visible form). */
|
||||
function icaclsText(path: string): string {
|
||||
const result = spawnSync('icacls', [path], { encoding: 'utf8' })
|
||||
expect(result.status, `icacls failed: ${result.stderr}`).toBe(0)
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
describe.skipIf(!isWin32)('AclWriteGrant (server-side materialization)', () => {
|
||||
const scratchDirs: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function scratch(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-grant-'))
|
||||
scratchDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
it('create parses the SID fail-closed: a malformed SID throws before anything is granted', () => {
|
||||
expect(() => AclWriteGrant.create('S-1-4-abc-1')).toThrow(/ConvertStringSidToSidW/u)
|
||||
})
|
||||
|
||||
it('add materializes the ACE (idempotently) and reports grant order; dispose revokes revocable paths and keeps standing paths standing', () => {
|
||||
const dir = scratch()
|
||||
const standingDir = scratch()
|
||||
const grant = AclWriteGrant.create('S-1-4-9000-77')
|
||||
grant.add(dir) // revocable: the session-temp lifecycle
|
||||
grant.add(standingDir, true) // standing: the workspace reuse cache
|
||||
expect(grant.paths).toEqual([standingDir, dir])
|
||||
expect(icaclsText(dir)).toContain('S-1-4-9000-77')
|
||||
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
|
||||
// A second add over the standing exact ACE is a DACL-read no-op: the
|
||||
// grant stays exactly one ACE (the reuse across sessions/restarts).
|
||||
grant.add(dir)
|
||||
grant.add(standingDir, true)
|
||||
expect(icaclsText(dir)).toContain('S-1-4-9000-77')
|
||||
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
|
||||
grant.dispose()
|
||||
expect(icaclsText(dir)).not.toContain('S-1-4-9000-77')
|
||||
expect(icaclsText(standingDir)).toContain('S-1-4-9000-77')
|
||||
})
|
||||
|
||||
it('two grants with different SIDs coexist and revoke independently', () => {
|
||||
const dir = scratch()
|
||||
const grantA = AclWriteGrant.create('S-1-4-9000-78')
|
||||
const grantB = AclWriteGrant.create('S-1-4-9000-79')
|
||||
grantA.add(dir)
|
||||
grantB.add(dir)
|
||||
expect(icaclsText(dir)).toContain('S-1-4-9000-78')
|
||||
expect(icaclsText(dir)).toContain('S-1-4-9000-79')
|
||||
grantA.dispose()
|
||||
expect(icaclsText(dir)).not.toContain('S-1-4-9000-78')
|
||||
expect(icaclsText(dir)).toContain('S-1-4-9000-79')
|
||||
grantB.dispose()
|
||||
expect(icaclsText(dir)).not.toContain('S-1-4-9000-79')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* End-to-end probe of the ACL write-restriction sandbox, using the same
|
||||
* probes as the POC verification harness: the confined child must be able to
|
||||
* write into the granted target and temp directories, must be DENIED writing
|
||||
* anywhere else, and (documented boundary) may still READ outside — the
|
||||
* WRITE_RESTRICTED token intersects write accesses only.
|
||||
*
|
||||
* The escape target sits in its own scratch dir under the system temp
|
||||
* directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never
|
||||
* defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the
|
||||
* whole real temp tree) and the writable dir is a separate mkdtemp directory
|
||||
* that contains neither sibling. Nothing under the user profile is touched.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { AclSandbox } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
|
||||
function pwshAvailable(): boolean {
|
||||
try {
|
||||
execFileSync('where.exe', ['pwsh'], { stdio: 'ignore' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () => {
|
||||
let scratchRoot!: string
|
||||
let writableDir!: string
|
||||
let isolatedTemp!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let sandbox: AclSandbox
|
||||
|
||||
beforeAll(async () => {
|
||||
scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-temp-'))
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
// tempDir is passed explicitly: GetTempPathW reads the native environment
|
||||
// block, which host runtimes (vitest worker pools) may not keep in sync
|
||||
// with process.env — and a real-temp grant would inherit over every
|
||||
// temp subdirectory, including this test's scratch dir.
|
||||
sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' })
|
||||
await sandbox.init()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
sandbox.dispose()
|
||||
rmSync(scratchRoot, { recursive: true, force: true })
|
||||
rmSync(isolatedTemp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('allows writes only in granted directories and denies the escape write', async () => {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
].join('')
|
||||
const child = sandbox.spawn({
|
||||
command: 'pwsh',
|
||||
args: ['/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe],
|
||||
cwd: writableDir,
|
||||
})
|
||||
const result = await child.wait()
|
||||
const output = result.stdout.toString('utf8') + result.stderr.toString('utf8')
|
||||
|
||||
expect(result.exitCode, `child output:\n${output}`).toBe(0)
|
||||
expect(output, `child output:\n${output}`).toContain('TARGET-WRITE: OK')
|
||||
expect(output, `child output:\n${output}`).toContain('TEMP-WRITE: OK')
|
||||
expect(output, `child output:\n${output}`).toContain('ESCAPE-WRITE: DENIED')
|
||||
// Documented boundary: WRITE_RESTRICTED intersects write accesses only,
|
||||
// so reads outside the allowlist still succeed.
|
||||
expect(output, `child output:\n${output}`).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(escapeFile)).toBe(false)
|
||||
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => {
|
||||
// A malformed SID makes ConvertStringSidToSidW fail; init must throw
|
||||
// before any grant is applied and never spawn unrestricted.
|
||||
const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
|
||||
await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u)
|
||||
}, 15_000)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The win32 chain's argv contract, denial dialect, and runner-failure rules,
|
||||
* exercised through the REAL LocalSandboxProvider.confine() with an injected
|
||||
* platform and runner argv prefix. Platform-independent assertions: they run
|
||||
* in every CI lane (Windows included, where sandbox-local's own POSIX-only
|
||||
* suites are excluded) — the end-to-end runner behavior lives in
|
||||
* runner.spec.ts on win32 hosts.
|
||||
*/
|
||||
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
|
||||
const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
||||
|
||||
async function setup(internals: LocalSandboxProvider['internals']) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = internals
|
||||
return sandbox
|
||||
}
|
||||
|
||||
describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
|
||||
it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => {
|
||||
const probeWindowsAcl = vi.fn(() => true)
|
||||
const sandbox = await setup({
|
||||
platform: 'win32',
|
||||
windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'],
|
||||
probeWindowsAcl,
|
||||
})
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], WW)
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', '/ws',
|
||||
'--temp', tmpdir(),
|
||||
'--mode', 'workspace-write',
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
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: '] }])
|
||||
// A sole candidate is selected unprobed.
|
||||
expect(probeWindowsAcl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('read-only: same runner and contract, read-only mode flag', async () => {
|
||||
const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* quoteArg unit tests plus a round-trip through the REAL CommandLineToArgvW
|
||||
* parser (shell32.dll, shellapi.h line ~867:
|
||||
* `LPWSTR *CommandLineToArgvW(LPCWSTR lpCmdLine, int *pNumArgs)`) on win32.
|
||||
*
|
||||
* CommandLineToArgvW applies the documented backslash rule (2n backslashes
|
||||
* before a quote produce n backslashes and toggle quoting; 2n+1 produce n
|
||||
* backslashes and a literal quote) to every token EXCEPT the first — the
|
||||
* first token is parsed with backslashes literal and quotes toggling
|
||||
* (verified empirically on this machine, Windows 11 build 26200). The
|
||||
* round-trip therefore prepends a plain program token, exactly like
|
||||
* buildCommandLine's real callers do, so the arguments under test land on
|
||||
* the rule-applying tokens.
|
||||
*
|
||||
* Reading argv from CommandLineToArgvW: koffi cannot decode the returned
|
||||
* LPWSTR* contents directly (the pointed-to strings are not koffi-registered
|
||||
* references), so each string is copied with lstrcpynW (winbase.h line
|
||||
* ~1500) into a Node Buffer and read as UTF-16LE; lengths come from
|
||||
* lstrlenW (winbase.h line ~1506); the argv block is freed with LocalFree
|
||||
* (winbase.h line ~1127) — CommandLineToArgvW's documented contract.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildCommandLine, quoteArg } from '../src/spawn.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
|
||||
/**
|
||||
* Table cases: input argv entry → the exact command-line fragment quoteArg
|
||||
* must produce. Trailing-backslash inputs are the regression: the closing
|
||||
* quote must be preceded by DOUBLED backslashes, or the parser reads them as
|
||||
* escaping the closing quote.
|
||||
*/
|
||||
const cases: Array<[input: string, quoted: string]> = [
|
||||
['', '""'],
|
||||
['a', 'a'],
|
||||
['a b', '"a b"'],
|
||||
['a"b', '"a\\"b"'],
|
||||
['a\\b', 'a\\b'],
|
||||
['a b\\', '"a b\\\\"'],
|
||||
['a b\\\\', '"a b\\\\\\\\"'],
|
||||
['a b\\\\\\', '"a b\\\\\\\\\\\\"'],
|
||||
['a\\\\"b', '"a\\\\\\\\\\"b"'],
|
||||
]
|
||||
|
||||
describe('quoteArg', () => {
|
||||
it.each(cases)('quotes %j as %j', (input, quoted) => {
|
||||
expect(quoteArg(input)).toBe(quoted)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!isWin32)('CommandLineToArgvW round-trip', () => {
|
||||
it('parses quoteArg+join back to the exact original argv', async () => {
|
||||
const { default: koffi } = await import('koffi')
|
||||
const PVOID = koffi.pointer('void')
|
||||
const shell32 = koffi.load('shell32.dll')
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const commandLineToArgvW = shell32.func('__stdcall', 'CommandLineToArgvW', PVOID, ['str16', koffi.pointer('int')])
|
||||
const lstrcpynW = kernel32.func('__stdcall', 'lstrcpynW', PVOID, [PVOID, PVOID, 'int'])
|
||||
const lstrlenW = kernel32.func('__stdcall', 'lstrlenW', 'int', [PVOID])
|
||||
const localFree = kernel32.func('__stdcall', 'LocalFree', PVOID, [PVOID])
|
||||
|
||||
const parse = (commandLine: string): string[] => {
|
||||
const countSlot = koffi.alloc('int', 1) as unknown
|
||||
const argvBlock = commandLineToArgvW(commandLine, countSlot) as unknown
|
||||
try {
|
||||
if (argvBlock === null) throw new Error('CommandLineToArgvW returned NULL')
|
||||
const count = koffi.decode(countSlot, 0, 'int') as number
|
||||
const table = Buffer.from(koffi.view(argvBlock, count * 8))
|
||||
const parsed: string[] = []
|
||||
for (let index = 0; index < count; index++) {
|
||||
const stringAddress = table.readBigUInt64LE(index * 8)
|
||||
const copied = Buffer.alloc(2048)
|
||||
lstrcpynW(copied, stringAddress, copied.length / 2)
|
||||
const length = lstrlenW(copied) as number
|
||||
parsed.push(copied.subarray(0, length * 2).toString('utf16le'))
|
||||
}
|
||||
return parsed
|
||||
} finally {
|
||||
localFree(argvBlock)
|
||||
}
|
||||
}
|
||||
|
||||
const argv = ['', 'a', 'a b', 'a"b', 'a\\b', 'a b\\', 'a b\\\\', 'a b\\\\\\', 'a\\\\"b']
|
||||
expect(parse(buildCommandLine('prog.exe', argv))).toEqual(['prog.exe', ...argv])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* End-to-end runner tests: spawn the REAL runner entry through tsx (exactly
|
||||
* the argv shape dsh-sandbox-local's confine() builds), with piped stdio
|
||||
* inherited through the runner into the confined child — the same chain a
|
||||
* production confined execution walks.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { AclWriteGrant } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
|
||||
|
||||
// Functional probe, not where.exe: spawnSync never throws on a missing
|
||||
// binary (status null) and where.exe exits 1 without pwsh — only an actual
|
||||
// pwsh invocation's exit status is truth.
|
||||
function pwshAvailable(): boolean {
|
||||
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
||||
}
|
||||
|
||||
function runRunner(args: string[], timeoutMs = 30_000) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx/esm', runnerEntry, ...args], {
|
||||
timeout: timeoutMs,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
||||
let scratchRoot!: string
|
||||
let writableDir!: string
|
||||
let isolatedTemp!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
// The ambient-writable probe target: a subdirectory of C:\Users\Public.
|
||||
// INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public
|
||||
// tree's INTERACTIVE grant must NOT satisfy the write check — the ambient
|
||||
// boundary the dual-list design closes (bot-reported blind spot). The
|
||||
// Public tree may be unavailable or unwritable for the test user on some
|
||||
// hosts; the probe test skips itself when the directory cannot be created.
|
||||
let publicProbeDir: string | undefined
|
||||
|
||||
beforeAll(() => {
|
||||
scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-'))
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
try {
|
||||
publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-'))
|
||||
} catch {
|
||||
publicProbeDir = undefined
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratchRoot, { recursive: true, force: true })
|
||||
rmSync(isolatedTemp, { recursive: true, force: true })
|
||||
if (publicProbeDir !== undefined) rmSync(publicProbeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('workspace-write: the confined child writes granted directories only', () => {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
// The restricted token puts pwsh into ConstrainedLanguage in BOTH modes
|
||||
// (documented Known Limitation) — pinned here so a token change that
|
||||
// silently restores FullLanguage is caught.
|
||||
'\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;',
|
||||
`try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
|
||||
// Authenticated Users is absent from BOTH lists: the WMI namespace
|
||||
// security check fails (0x80041003) — CIM is unavailable under every
|
||||
// confined mode (the documented contract; the C:\-root tree-creation
|
||||
// escape is closed in both as the other side of the trade).
|
||||
"try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}",
|
||||
].join('')
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage')
|
||||
expect(result.stdout).toContain('TARGET-WRITE: OK')
|
||||
expect(result.stdout).toContain('TEMP-WRITE: OK')
|
||||
expect(result.stdout).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout).toContain('SECRET-READ: OK')
|
||||
expect(result.stdout).toContain('CIM: DENIED')
|
||||
expect(existsSync(escapeFile)).toBe(false)
|
||||
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
'\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;',
|
||||
`try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
// The NUL device is a securable object: strict zero grants deny it too.
|
||||
'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};',
|
||||
// PowerShell's $null redirection discards without opening NUL — must keep working.
|
||||
'echo hi > $null;\'DOLLAR-NULL: OK\';',
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
|
||||
// BOTH lists drop Authenticated Users: the WMI namespace security
|
||||
// check fails (0x80041003) — the documented CIM boundary of every
|
||||
// confined mode, the price of the zero ambient-write surface.
|
||||
"try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}",
|
||||
].join('')
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only',
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage')
|
||||
expect(result.stdout).toContain('TARGET-WRITE: DENIED')
|
||||
expect(result.stdout).toContain('TEMP-WRITE: DENIED')
|
||||
expect(result.stdout).toContain('NUL-WRITE: DENIED')
|
||||
expect(result.stdout).toContain('DOLLAR-NULL: OK')
|
||||
expect(result.stdout).toContain('SECRET-READ: OK')
|
||||
expect(result.stdout).toContain('CIM: DENIED')
|
||||
expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false)
|
||||
}, 30_000)
|
||||
|
||||
it('workspace-write: Remove-Item and Rename-Item succeed in the granted workspace (DELETE + FILE_DELETE_CHILD)', () => {
|
||||
// Deleting a file and renaming a directory both hit the second access
|
||||
// check on the workspace itself: the grant must carry DELETE (on the
|
||||
// object) and FILE_DELETE_CHILD (on its parent).
|
||||
const victimFile = join(writableDir, 'delete-me.txt')
|
||||
writeFileSync(victimFile, 'remove me')
|
||||
const victimDir = join(writableDir, 'rename-me')
|
||||
mkdirSync(victimDir)
|
||||
const renamedDir = join(writableDir, 'renamed-by-child')
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Remove-Item -LiteralPath '${victimFile}' -ErrorAction Stop;'DELETE-FILE: OK'}catch{'DELETE-FILE: DENIED'};`,
|
||||
`try{Rename-Item -LiteralPath '${victimDir}' -NewName 'renamed-by-child' -ErrorAction Stop;'RENAME-DIR: OK'}catch{'RENAME-DIR: DENIED'}`,
|
||||
].join('')
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).toContain('DELETE-FILE: OK')
|
||||
expect(result.stdout).toContain('RENAME-DIR: OK')
|
||||
expect(existsSync(victimFile)).toBe(false)
|
||||
expect(existsSync(renamedDir)).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => {
|
||||
const writeSid = 'S-1-4-9000-99'
|
||||
const privateTemp = join(isolatedTemp, 'private-subdir')
|
||||
mkdirSync(privateTemp)
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
grant.add(privateTemp)
|
||||
try {
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`,
|
||||
"'TEMP-ENV: ' + $env:TEMP;",
|
||||
"'TMP-ENV: ' + $env:TMP",
|
||||
].join('')
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
// The runner granted nothing (only the caller's private-temp grant
|
||||
// stands): the workspace write is denied, the private temp write lands,
|
||||
// and the child's TMP/TEMP point at the private subdirectory.
|
||||
expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED')
|
||||
expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK')
|
||||
expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`)
|
||||
expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`)
|
||||
expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false)
|
||||
expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true)
|
||||
} finally {
|
||||
grant.dispose()
|
||||
rmSync(privateTemp, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => {
|
||||
// Two-layer pin of the grandchild-spawn boundary:
|
||||
// - the token default DACL carries a restricting-SID ACE (set in init),
|
||||
// so ANONYMOUS pipe creation (CreatePipe — the token-default-DACL
|
||||
// consumer) works and inherited/ignored stdio spawns succeed;
|
||||
// - libuv's pipe-stdio uses NAMED pipes, whose default security
|
||||
// descriptor is the Win32 layer's user-mode default SD template
|
||||
// (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS
|
||||
// read-only) — NOT the token default DACL, which is what the kernel
|
||||
// applies to a raw SD-null create — so the client-end open requests
|
||||
// write access no restricting SID is
|
||||
// granted: ERROR_ACCESS_DENIED, surfaced as spawn EPERM. That is the
|
||||
// POC-documented "no output redirection" boundary of WRITE_RESTRICTED
|
||||
// tokens; piped capture cannot work and is pinned as DENIED.
|
||||
const probe = [
|
||||
"const { spawnSync } = require('child_process');",
|
||||
"const t = (name, opts) => { const s = spawnSync(process.execPath, ['-e', '1'], { encoding: 'utf8', ...opts }); console.log(name + ':' + (s.status === 0 ? 'OK' : 'DENIED')); };",
|
||||
"t('inherit', { stdio: 'inherit' });",
|
||||
"t('ignore', { stdio: 'ignore' });",
|
||||
"t('pipe', { stdio: 'pipe' });",
|
||||
].join('')
|
||||
for (const mode of ['workspace-write', 'read-only'] as const) {
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode,
|
||||
'--', 'node', '-e', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('inherit:OK')
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('ignore:OK')
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('pipe:DENIED')
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('mode-downgrade leak regression: a STANDING workspace grant is inert under read-only and effective again on re-upgrade', () => {
|
||||
// The reported defect: a session that materialized its grant in
|
||||
// workspace-write keeps the ACE standing for the server lifetime. After
|
||||
// switching to read-only, the restricted token's read-only list must carry NO
|
||||
// orphan SID — the standing ACE stays but the pass-2 check cannot use
|
||||
// it, so the workspace write is denied (previously it LEAKED). The
|
||||
// switch back reuses the SAME standing ACE: the re-upgrade write lands
|
||||
// without any re-grant.
|
||||
const writeSid = 'S-1-4-9001-7'
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
grant.add(writableDir)
|
||||
try {
|
||||
const downgradeProbe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`,
|
||||
].join('')
|
||||
const downgraded = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid,
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe,
|
||||
])
|
||||
expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0)
|
||||
expect(downgraded.stdout).toContain('DOWNGRADE-WRITE: DENIED')
|
||||
expect(existsSync(join(writableDir, 'downgraded.txt'))).toBe(false)
|
||||
|
||||
const reupgradeProbe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`,
|
||||
].join('')
|
||||
const reupgraded = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe,
|
||||
])
|
||||
expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0)
|
||||
expect(reupgraded.stdout).toContain('REUPGRADE-WRITE: OK')
|
||||
expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true)
|
||||
} finally {
|
||||
grant.dispose()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('ambient-writable escape regression: a C:\\Users\\Public subdirectory is denied under BOTH modes (INTERACTIVE absent from both lists)', (ctx) => {
|
||||
// The Public tree grants write to INTERACTIVE; the D1-D6 matrix pinned
|
||||
// that removing INTERACTIVE from the restricting lists closes the escape.
|
||||
// The committed suites never probed it — this pins the ambient boundary
|
||||
// end to end with the real restricted token.
|
||||
if (publicProbeDir === undefined) {
|
||||
ctx.skip() // Public unavailable/unwritable on this host
|
||||
return
|
||||
}
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${publicProbeDir}\\public-escaped.txt' -Value ok -ErrorAction Stop;'PUBLIC-WRITE: OK (ESCAPE!)'}catch{'PUBLIC-WRITE: DENIED'}`,
|
||||
].join('')
|
||||
for (const mode of ['read-only', 'workspace-write'] as const) {
|
||||
const result = runRunner([
|
||||
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode,
|
||||
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
||||
])
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout, `mode: ${mode}`).toContain('PUBLIC-WRITE: DENIED')
|
||||
expect(existsSync(join(publicProbeDir, 'public-escaped.txt')), `mode: ${mode}`).toBe(false)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('runner-side failure: signature on stderr and exit 127, the command never runs', () => {
|
||||
const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write'])
|
||||
expect(result.status).toBe(127)
|
||||
expect(result.stderr).toContain('windows-acl-run: ')
|
||||
}, 15_000)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* workspaceWriteSid tests: the per-workspace write identity is deterministic
|
||||
* (the same canonical path always derives the same SID — the property the
|
||||
* cross-session grant reuse rests on), orphan-shaped, distinct across
|
||||
* workspaces, and byte-sensitive (the canonical path is the caller's
|
||||
* contract; an alias spelling derives a second identity, self-healing at
|
||||
* the cost of one extra tree propagation).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { workspaceWriteSid } from '../src/index.ts'
|
||||
|
||||
describe('workspaceWriteSid', () => {
|
||||
it('derives a stable orphan-shaped SID per workspace path', () => {
|
||||
const first = workspaceWriteSid('C:\\Users\\agent\\repo')
|
||||
const second = workspaceWriteSid('C:\\Users\\agent\\repo')
|
||||
expect(first).toBe(second)
|
||||
expect(first).toMatch(/^S-1-4-\d+-\d+$/u)
|
||||
})
|
||||
|
||||
it('derives distinct identities for distinct workspaces', () => {
|
||||
expect(workspaceWriteSid('C:\\Users\\agent\\repo-a')).not.toBe(workspaceWriteSid('C:\\Users\\agent\\repo-b'))
|
||||
})
|
||||
|
||||
it('is byte-sensitive: the canonical path is the caller\'s contract (an alias spelling derives a second identity)', () => {
|
||||
expect(workspaceWriteSid('C:\\Repo')).not.toBe(workspaceWriteSid('c:\\repo'))
|
||||
expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
// The confinement runner builds as its own entry (path-loaded by
|
||||
// dsh-sandbox-local's win32 chain), inlining the sandbox primitives while
|
||||
// koffi stays an external native require — the same shape as
|
||||
// directory-picker-native's worker entry.
|
||||
export default defineConfig({
|
||||
entry: { index: 'lib/types/index.js', invariant: 'lib/types/invariant.js', runner: 'lib/types/runner.js' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
// ABI probe: prints sizeof/offsetof/enum values from the actual MinGW Windows
|
||||
// headers on this machine. These numbers are the source of truth for the
|
||||
// koffi FFI definitions in the Node.js port.
|
||||
#include <Windows.h>
|
||||
#include <sddl.h>
|
||||
#include <AclAPI.h>
|
||||
#include <cstdio>
|
||||
#include <cstddef>
|
||||
|
||||
#define P(expr) printf("%-52s = %llu\n", #expr, (unsigned long long)(expr))
|
||||
|
||||
int wmain()
|
||||
{
|
||||
P(sizeof(void*));
|
||||
P(sizeof(HANDLE));
|
||||
P(sizeof(DWORD));
|
||||
P(sizeof(WORD));
|
||||
P(sizeof(BOOL));
|
||||
|
||||
P(sizeof(STARTUPINFOW));
|
||||
P(offsetof(STARTUPINFOW, cb));
|
||||
P(offsetof(STARTUPINFOW, lpReserved));
|
||||
P(offsetof(STARTUPINFOW, lpDesktop));
|
||||
P(offsetof(STARTUPINFOW, lpTitle));
|
||||
P(offsetof(STARTUPINFOW, dwX));
|
||||
P(offsetof(STARTUPINFOW, dwY));
|
||||
P(offsetof(STARTUPINFOW, dwXSize));
|
||||
P(offsetof(STARTUPINFOW, dwYSize));
|
||||
P(offsetof(STARTUPINFOW, dwXCountChars));
|
||||
P(offsetof(STARTUPINFOW, dwYCountChars));
|
||||
P(offsetof(STARTUPINFOW, dwFillAttribute));
|
||||
P(offsetof(STARTUPINFOW, dwFlags));
|
||||
P(offsetof(STARTUPINFOW, wShowWindow));
|
||||
P(offsetof(STARTUPINFOW, cbReserved2));
|
||||
P(offsetof(STARTUPINFOW, lpReserved2));
|
||||
P(offsetof(STARTUPINFOW, hStdInput));
|
||||
P(offsetof(STARTUPINFOW, hStdOutput));
|
||||
P(offsetof(STARTUPINFOW, hStdError));
|
||||
|
||||
P(sizeof(PROCESS_INFORMATION));
|
||||
P(offsetof(PROCESS_INFORMATION, hProcess));
|
||||
P(offsetof(PROCESS_INFORMATION, hThread));
|
||||
P(offsetof(PROCESS_INFORMATION, dwProcessId));
|
||||
P(offsetof(PROCESS_INFORMATION, dwThreadId));
|
||||
|
||||
P(sizeof(SECURITY_ATTRIBUTES));
|
||||
P(offsetof(SECURITY_ATTRIBUTES, nLength));
|
||||
P(offsetof(SECURITY_ATTRIBUTES, lpSecurityDescriptor));
|
||||
P(offsetof(SECURITY_ATTRIBUTES, bInheritHandle));
|
||||
|
||||
P(sizeof(TRUSTEE_W));
|
||||
P(offsetof(TRUSTEE_W, pMultipleTrustee));
|
||||
P(offsetof(TRUSTEE_W, MultipleTrusteeOperation));
|
||||
P(offsetof(TRUSTEE_W, TrusteeForm));
|
||||
P(offsetof(TRUSTEE_W, TrusteeType));
|
||||
P(offsetof(TRUSTEE_W, ptstrName));
|
||||
|
||||
P(sizeof(EXPLICIT_ACCESS_W));
|
||||
P(offsetof(EXPLICIT_ACCESS_W, grfAccessPermissions));
|
||||
P(offsetof(EXPLICIT_ACCESS_W, grfAccessMode));
|
||||
P(offsetof(EXPLICIT_ACCESS_W, grfInheritance));
|
||||
P(offsetof(EXPLICIT_ACCESS_W, Trustee));
|
||||
|
||||
P(sizeof(SID_AND_ATTRIBUTES));
|
||||
P(offsetof(SID_AND_ATTRIBUTES, Sid));
|
||||
P(offsetof(SID_AND_ATTRIBUTES, Attributes));
|
||||
|
||||
P(sizeof(TOKEN_GROUPS));
|
||||
P(offsetof(TOKEN_GROUPS, GroupCount));
|
||||
P(offsetof(TOKEN_GROUPS, Groups));
|
||||
|
||||
P(sizeof(TOKEN_MANDATORY_LABEL));
|
||||
|
||||
P(sizeof(SID));
|
||||
P(SECURITY_MAX_SID_SIZE);
|
||||
P(SID_MAX_SUB_AUTHORITIES);
|
||||
P(SID_REVISION);
|
||||
|
||||
P(TOKEN_ASSIGN_PRIMARY);
|
||||
P(TOKEN_DUPLICATE);
|
||||
P(TOKEN_QUERY);
|
||||
P(TOKEN_ADJUST_DEFAULT);
|
||||
|
||||
P(SE_GROUP_LOGON_ID);
|
||||
P(SE_GROUP_INTEGRITY);
|
||||
P(SE_GROUP_INTEGRITY_ENABLED);
|
||||
|
||||
P(FILE_GENERIC_WRITE);
|
||||
P((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE));
|
||||
P(STANDARD_RIGHTS_WRITE);
|
||||
P(DELETE);
|
||||
P(FILE_DELETE_CHILD);
|
||||
P(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE));
|
||||
|
||||
P(FILE_SHARE_READ);
|
||||
P(FILE_SHARE_WRITE);
|
||||
P(FILE_SHARE_DELETE);
|
||||
P(GENERIC_READ);
|
||||
P(GENERIC_WRITE);
|
||||
P(OPEN_ALWAYS);
|
||||
P(LOCKFILE_EXCLUSIVE_LOCK);
|
||||
P(LOCKFILE_FAIL_IMMEDIATELY);
|
||||
P(ERROR_LOCK_VIOLATION);
|
||||
P(INHERITED_ACE);
|
||||
|
||||
P(DISABLE_MAX_PRIVILEGE);
|
||||
P(SANDBOX_INERT);
|
||||
P(LUA_TOKEN);
|
||||
P(WRITE_RESTRICTED);
|
||||
|
||||
P((int)WinWorldSid);
|
||||
P((int)WinLocalLogonSid);
|
||||
P((int)WinConsoleLogonSid);
|
||||
|
||||
P((int)TokenUser);
|
||||
P((int)TokenGroups);
|
||||
P((int)TokenIntegrityLevel);
|
||||
|
||||
P((int)SE_FILE_OBJECT);
|
||||
P(DACL_SECURITY_INFORMATION);
|
||||
|
||||
P((int)TRUSTEE_IS_UNKNOWN);
|
||||
P((int)TRUSTEE_IS_SID);
|
||||
P((int)NOT_USED_ACCESS);
|
||||
P((int)GRANT_ACCESS);
|
||||
P((int)REVOKE_ACCESS);
|
||||
P(SUB_CONTAINERS_AND_OBJECTS_INHERIT);
|
||||
P(OBJECT_INHERIT_ACE);
|
||||
P(CONTAINER_INHERIT_ACE);
|
||||
|
||||
P(CREATE_SUSPENDED);
|
||||
P(CREATE_NO_WINDOW);
|
||||
P(DETACHED_PROCESS);
|
||||
P(CREATE_NEW_CONSOLE);
|
||||
P(STARTF_USESTDHANDLES);
|
||||
P(HANDLE_FLAG_INHERIT);
|
||||
P(INFINITE);
|
||||
|
||||
P(LMEM_FIXED);
|
||||
P(LMEM_ZEROINIT);
|
||||
P(LPTR);
|
||||
|
||||
P(FORMAT_MESSAGE_ALLOCATE_BUFFER);
|
||||
P(FORMAT_MESSAGE_FROM_SYSTEM);
|
||||
P(FORMAT_MESSAGE_IGNORE_INSERTS);
|
||||
P(MAX_PATH);
|
||||
|
||||
P(ERROR_SUCCESS);
|
||||
P(ERROR_INSUFFICIENT_BUFFER);
|
||||
P(ERROR_NO_MORE_ITEMS);
|
||||
P(ERROR_INVALID_PARAMETER);
|
||||
P(ERROR_INVALID_SID);
|
||||
P(ERROR_NONE_MAPPED);
|
||||
P(ERROR_BROKEN_PIPE);
|
||||
|
||||
// Job object (runner kill-on-close hardening)
|
||||
P(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
|
||||
P(sizeof(JOBOBJECT_BASIC_LIMIT_INFORMATION));
|
||||
P(sizeof(IO_COUNTERS));
|
||||
P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation));
|
||||
P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags));
|
||||
P(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, ProcessMemoryLimit));
|
||||
P((int)JobObjectExtendedLimitInformation);
|
||||
P(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE);
|
||||
|
||||
// static assertions for the values the koffi module will hardcode
|
||||
static_assert(sizeof(STARTUPINFOW) == 104, "STARTUPINFOW size");
|
||||
static_assert(sizeof(PROCESS_INFORMATION) == 24, "PROCESS_INFORMATION size");
|
||||
static_assert(sizeof(SECURITY_ATTRIBUTES) == 24, "SECURITY_ATTRIBUTES size");
|
||||
static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size");
|
||||
static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size");
|
||||
static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size");
|
||||
static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE");
|
||||
static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights");
|
||||
static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr");
|
||||
static_assert(FILE_GENERIC_WRITE == 0x120116, "generic write");
|
||||
static_assert((FILE_GENERIC_WRITE & ~STANDARD_RIGHTS_WRITE) == 0x100116, "poc grant mask");
|
||||
static_assert(DELETE == 0x10000 && FILE_DELETE_CHILD == 0x40, "delete rights");
|
||||
static_assert(((FILE_GENERIC_WRITE | DELETE | FILE_DELETE_CHILD) & ~STANDARD_RIGHTS_WRITE) == 0x110156, "sandbox grant mask");
|
||||
static_assert(FILE_SHARE_READ == 0x1 && FILE_SHARE_WRITE == 0x2 && FILE_SHARE_DELETE == 0x4, "share modes");
|
||||
static_assert(OPEN_ALWAYS == 4, "open always");
|
||||
static_assert(LOCKFILE_EXCLUSIVE_LOCK == 0x2 && LOCKFILE_FAIL_IMMEDIATELY == 0x1, "lockfile flags");
|
||||
static_assert(ERROR_LOCK_VIOLATION == 33, "lock violation");
|
||||
static_assert(INHERITED_ACE == 0x10, "inherited ace flag");
|
||||
static_assert(GRANT_ACCESS == 1 && REVOKE_ACCESS == 4, "access modes");
|
||||
static_assert(SUB_CONTAINERS_AND_OBJECTS_INHERIT == 0x3, "inheritance");
|
||||
static_assert(CREATE_NO_WINDOW == 0x08000000, "create no window");
|
||||
static_assert(STARTF_USESTDHANDLES == 0x100, "std handles flag");
|
||||
static_assert(sizeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION) == 144, "job extended limit size");
|
||||
static_assert(offsetof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION, BasicLimitInformation) + offsetof(JOBOBJECT_BASIC_LIMIT_INFORMATION, LimitFlags) == 16, "job LimitFlags offset");
|
||||
static_assert(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0x2000, "kill on job close flag");
|
||||
static_assert(JobObjectExtendedLimitInformation == 9, "extended limit class");
|
||||
printf("\nstatic_asserts passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md
|
||||
README.md: 50b7eff1a287ee0bc2432a7bf409586ff879920e
|
||||
README.zh.md: ff68a49f5487b5da9700698c5376199727f1cd5f
|
||||
README.md: d8e2cf18e8dfc50a60e6c0f46a96e1047081736c
|
||||
README.zh.md: c5ca0b100af523c5050ff1f96bdd67a853a0ea2a
|
||||
@@ -23,7 +23,7 @@ Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-b
|
||||
##### Exact error
|
||||
|
||||
```markdown
|
||||
sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access.
|
||||
sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
##### 精确错误
|
||||
|
||||
```markdown
|
||||
sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access.
|
||||
sandbox mode "<mode>" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export {
|
||||
ESCALATION_TARGETS,
|
||||
@@ -40,6 +41,14 @@ export interface SandboxExecutionPolicy {
|
||||
mode: SandboxMode
|
||||
/** Absolute root directory `workspace-write` may write under. */
|
||||
workspaceRoot: string
|
||||
/**
|
||||
* Opaque identity of the calling session (the branded `dsh-session`
|
||||
* SessionId). Backends key per-session state off it (e.g. the windows-acl
|
||||
* per-session private temp subdirectory — the write grant itself is
|
||||
* per-workspace, derived from the workspace root); absent for agentless
|
||||
* calls, which fall back to per-call backend state.
|
||||
*/
|
||||
sessionId?: SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,8 +133,9 @@ export class SandboxUnavailableError extends HarnessError {
|
||||
super(
|
||||
`sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; `
|
||||
+ 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing '
|
||||
+ 'kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement '
|
||||
+ 'backend yet — or switch the consumer to danger-full-access.'
|
||||
+ 'kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL '
|
||||
+ 'restricted-token runner can start (Windows) — otherwise switch the consumer to '
|
||||
+ 'danger-full-access.'
|
||||
+ (detail === undefined ? '' : ` Runner failure: ${detail}`),
|
||||
SANDBOX_UNAVAILABLE,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user