fix(sandbox): isolate Windows temp capabilities
This commit is contained in:
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
* Server-side write-grant materialization. The sandbox seam holds one
|
||||
* standing workspace grant per workspace and one revocable temp grant per
|
||||
* live session/workspace pair. Workspace identities survive by deterministic
|
||||
* derivation and their standing ACE; temp identities derive from random
|
||||
* private paths and are deliberately new after a restart.
|
||||
*
|
||||
* Fail-closed: `add` throws on any grant failure and the caller disposes the
|
||||
* instance (revoking every path granted so far); `dispose` revokes every
|
||||
@@ -19,7 +16,7 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from '.
|
||||
import type { NativePtr, Win32Bindings } from './ffi.ts'
|
||||
|
||||
/**
|
||||
* One write SID's server-lifetime grant materialization: the parsed SID
|
||||
* One write SID's provider-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
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* 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
|
||||
* token whose restricting SIDs include distinct workspace and temp write
|
||||
* SIDs that this sandbox adds to their owning directories' DACLs — the
|
||||
* intersection check then allows writes exactly where either capability has
|
||||
* a Write ACE, and nowhere else those SIDs are concerned (the 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
|
||||
@@ -15,7 +15,9 @@
|
||||
* 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
|
||||
* per session for. Each private temp directory instead receives its own SID,
|
||||
* so sibling sessions sharing a workspace cannot enter one another's temp
|
||||
* trees. 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):
|
||||
@@ -24,15 +26,14 @@
|
||||
* - 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
|
||||
* - the private 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`
|
||||
* inheritable ACE never outlives its session's temp directory. The
|
||||
* ambient temp root is never granted implicitly. 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.
|
||||
@@ -44,7 +45,7 @@ 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 { allocPtrSlot, decodePtr, 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'
|
||||
@@ -52,18 +53,17 @@ import * as abi from './win32-abi.ts'
|
||||
|
||||
export { quoteArg } from './spawn.ts'
|
||||
export { AclWriteGrant } from './grant.ts'
|
||||
export { workspaceWriteSid } from './workspace-sid.ts'
|
||||
export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
|
||||
export { Win32Error } from './errors.ts'
|
||||
|
||||
/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */
|
||||
/** Construction options: the workspace/temp allowlists and their distinct SID identities. */
|
||||
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 explicit temp grant. Ambient
|
||||
* Everyone authority remains part of the backend's documented partial
|
||||
* boundary — see README.
|
||||
* Existing private temp directory to grant. Workspace-write callers must
|
||||
* pass it explicitly or pass null to disable temp writes; the ambient temp
|
||||
* root is never an implicit grant. Read-only accepts only null/undefined.
|
||||
*/
|
||||
tempDir?: string | null
|
||||
/**
|
||||
@@ -74,6 +74,13 @@ export interface AclSandboxOptions {
|
||||
* outlives every instance and later provisions hit the exact-ACE skip.
|
||||
*/
|
||||
writeSid?: string
|
||||
/**
|
||||
* The private temp directory's write SID. Required whenever
|
||||
* workspace-write grants a temp directory, absent otherwise. It must be
|
||||
* distinct from {@link writeSid}, so sibling sessions sharing a workspace
|
||||
* cannot use the standing workspace capability in one another's temp tree.
|
||||
*/
|
||||
tempWriteSid?: string
|
||||
/**
|
||||
* The file-effect mode this instance confines under — selects the
|
||||
* restricted token's restricting-SID list (I for read-only, J for
|
||||
@@ -85,7 +92,7 @@ export interface AclSandboxOptions {
|
||||
/**
|
||||
* 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 —
|
||||
* workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely —
|
||||
* the caller holds the grants for its own lifetime and revokes them.
|
||||
*/
|
||||
manageDacls?: boolean
|
||||
@@ -135,8 +142,10 @@ export interface AclSandboxChild {
|
||||
export class AclSandbox {
|
||||
/** Absolute writable directories (constructor-validated). */
|
||||
readonly writableDirs: string[]
|
||||
/** The write SID string whose ACEs form the write allowlist (workspace-write only). */
|
||||
/** The workspace SID string whose ACEs form the workspace allowlist. */
|
||||
readonly writeSid: string | undefined
|
||||
/** The private temp directory's write SID (workspace-write with temp only). */
|
||||
readonly tempWriteSid: 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
|
||||
@@ -145,9 +154,10 @@ export class AclSandbox {
|
||||
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 tempWriteSidPtr: NativePtr | undefined
|
||||
/** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */
|
||||
private sidAllocations: NativePtr[] = []
|
||||
private grantedPaths: string[] = []
|
||||
private grantedPaths: Array<{ path: string; sidPtr: NativePtr }> = []
|
||||
|
||||
constructor(options: AclSandboxOptions) {
|
||||
this.mode = options.mode
|
||||
@@ -161,9 +171,28 @@ export class AclSandbox {
|
||||
})
|
||||
this.tempDirOption = options.tempDir
|
||||
this.writeSid = options.writeSid
|
||||
this.tempWriteSid = options.tempWriteSid
|
||||
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()')
|
||||
}
|
||||
if (this.mode === 'workspace-write' && this.tempDirOption === undefined) {
|
||||
throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null')
|
||||
}
|
||||
if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) {
|
||||
throw new Error('AclSandbox read-only does not accept a temp directory')
|
||||
}
|
||||
if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) {
|
||||
throw new Error('AclSandbox read-only does not accept write SIDs')
|
||||
}
|
||||
if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) {
|
||||
throw new Error('AclSandbox workspace-write with temp requires a temp write SID — derive it via tempWriteSid()')
|
||||
}
|
||||
if (this.tempDirOption === null && this.tempWriteSid !== undefined) {
|
||||
throw new Error('AclSandbox temp write SID requires a temp directory')
|
||||
}
|
||||
if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) {
|
||||
throw new Error('AclSandbox workspace and temp write SIDs must be distinct')
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolved temp directory (available after init; null when temp grants are disabled). */
|
||||
@@ -171,56 +200,53 @@ export class AclSandbox {
|
||||
return this.tempDirResolved
|
||||
}
|
||||
|
||||
/** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */
|
||||
/** Create the restricted token and apply the capability-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)
|
||||
let currentTokenOpen = true
|
||||
let restrictedToken: NativePtr | undefined
|
||||
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 parseSid = (sid: string): NativePtr => {
|
||||
const sidSlot = allocPtrSlot()
|
||||
if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) {
|
||||
throwLastError(api, 'ConvertStringSidToSidW', this.writeSid)
|
||||
if (api.convertStringSidToSidW(sid, sidSlot) === 0) {
|
||||
throwLastError(api, 'ConvertStringSidToSidW', sid)
|
||||
}
|
||||
const parsedSid = decodePtr(sidSlot)
|
||||
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid)
|
||||
this.writeSidPtr = parsedSid
|
||||
writeSidPtr = parsedSid
|
||||
if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid)
|
||||
return parsedSid
|
||||
}
|
||||
this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid)
|
||||
this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid)
|
||||
|
||||
const tempDir = this.tempDirOption === null
|
||||
? null
|
||||
: this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api)
|
||||
const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption
|
||||
if (tempDir === undefined) throw new Error('AclSandbox workspace-write temp directory was not resolved')
|
||||
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
|
||||
}
|
||||
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).
|
||||
// REVOCABLE (dispose() removes it before the private directory is
|
||||
// deleted; the ambient temp root is never granted).
|
||||
if (this.manageDacls) {
|
||||
if (writeSidPtr !== undefined) {
|
||||
if (this.writeSidPtr !== undefined) {
|
||||
for (const path of this.writableDirs) {
|
||||
grantWrite(api, path, writeSidPtr)
|
||||
grantWrite(api, path, this.writeSidPtr)
|
||||
}
|
||||
if (tempDir !== null) {
|
||||
if (tempDir !== null && this.tempWriteSidPtr !== undefined) {
|
||||
// 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)
|
||||
this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr })
|
||||
grantWrite(api, tempDir, this.tempWriteSidPtr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,40 +254,55 @@ export class AclSandbox {
|
||||
this.sidAllocations.push(logonSid)
|
||||
const worldSid = makeWellKnownSid(api, abi.WinWorldSid)
|
||||
this.sidAllocations.push(worldSid)
|
||||
const restricted = createRestrictedToken(
|
||||
api, currentToken, logonSid, writeSidPtr,
|
||||
const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined)
|
||||
restrictedToken = createRestrictedToken(
|
||||
api, currentToken, logonSid, writeSids,
|
||||
{ world: worldSid },
|
||||
this.mode,
|
||||
)
|
||||
this.token = restrictedToken
|
||||
// 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
|
||||
// restricting SID (the PRIVATE temp SID when present, otherwise the
|
||||
// workspace SID, or 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. Choosing the temp SID prevents default-DACL
|
||||
// objects in one session's temp tree from acquiring the shared
|
||||
// workspace capability.
|
||||
setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid)
|
||||
if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token')
|
||||
currentTokenOpen = false
|
||||
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)
|
||||
}
|
||||
if (currentTokenOpen && api.closeHandle(currentToken) === 0) {
|
||||
cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'current process token after init failure'))
|
||||
}
|
||||
if (restrictedToken !== undefined && api.closeHandle(restrictedToken) === 0) {
|
||||
cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'restricted token after init failure'))
|
||||
}
|
||||
for (const grant of this.grantedPaths) {
|
||||
try {
|
||||
revokeWrite(api, grant.path, grant.sidPtr)
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
|
||||
if (sidPtr === undefined) continue
|
||||
try {
|
||||
const freed = api.localFree(sidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label)
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
for (const sidPtr of this.sidAllocations.splice(0)) {
|
||||
@@ -272,10 +313,14 @@ export class AclSandbox {
|
||||
cleanupFailures.push(cleanupError)
|
||||
}
|
||||
}
|
||||
this.token = undefined
|
||||
this.writeSidPtr = undefined
|
||||
this.tempWriteSidPtr = undefined
|
||||
this.grantedPaths = []
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError(
|
||||
[error, ...cleanupFailures],
|
||||
`AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`,
|
||||
`AclSandbox init failed and ${cleanupFailures.length} cleanup operation(s) also failed`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
@@ -341,20 +386,20 @@ export class AclSandbox {
|
||||
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)
|
||||
}
|
||||
if (this.manageDacls) {
|
||||
for (const grant of this.grantedPaths) {
|
||||
try {
|
||||
revokeWrite(api, grant.path, grant.sidPtr)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) {
|
||||
if (sidPtr === undefined) continue
|
||||
try {
|
||||
const freed = api.localFree(writeSidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID')
|
||||
const freed = api.localFree(sidPtr)
|
||||
if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
@@ -378,6 +423,7 @@ export class AclSandbox {
|
||||
this.api = undefined
|
||||
this.token = undefined
|
||||
this.writeSidPtr = undefined
|
||||
this.tempWriteSidPtr = undefined
|
||||
this.grantedPaths = []
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`)
|
||||
|
||||
@@ -10,33 +10,29 @@
|
||||
* keep the same contract):
|
||||
* [node, runner.js, '--workspace', <dir>, '--temp', <dir>,
|
||||
* '--mode', <read-only|workspace-write>,
|
||||
* ['--write-sid', <S-1-4-…>], '--', <argv...>]
|
||||
* ['--write-sid', <S-1-4-…>,
|
||||
* '--temp-write-sid', <S-1-4-…>], '--', <argv...>]
|
||||
*
|
||||
* Modes:
|
||||
* - workspace-write: the workspace and temp directories carry the orphan-SID
|
||||
* Write grant; other ACL-addressable writes are denied except for the
|
||||
* documented Everyone and hard-link boundaries.
|
||||
* - workspace-write: the workspace and temp directories carry distinct
|
||||
* capability-SID Write grants; other ACL-addressable writes are denied
|
||||
* except for the documented Everyone and hard-link boundaries.
|
||||
* - read-only: no orphan-SID grants; 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.
|
||||
* (logon SID, EVERYONE) and differ only by the capabilities.
|
||||
*
|
||||
* `--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
|
||||
* `--write-sid` + `--temp-write-sid`: the seam's grant contract — the
|
||||
* CALLER has already materialized distinct workspace and private-temp ACEs
|
||||
* and owns their revocation, so the runner neither grants nor revokes
|
||||
* (`manageDacls: false`). Both values are checked against their owning paths.
|
||||
* Without the pair (standalone/agentless use), workspace-write treats
|
||||
* `--temp` as a ROOT, creates a random private child directory, derives its
|
||||
* own temp SID, and removes that directory after the child exits. In both
|
||||
* flows the runner rewrites TMP/TEMP in its OWN environment to the private
|
||||
* directory before spawning; the child inherits that 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).
|
||||
@@ -48,11 +44,12 @@
|
||||
* @module @deepseek-ai/dsh-sandbox-windows-acl/runner
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { win32 } from './ffi.ts'
|
||||
import { AclSandbox } from './index.ts'
|
||||
import { workspaceWriteSid } from './workspace-sid.ts'
|
||||
import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts'
|
||||
|
||||
const RUNNER_SIGNATURE = 'windows-acl-run'
|
||||
const RUNNER_FAILURE_EXIT = 127
|
||||
@@ -70,6 +67,7 @@ interface ParsedArgs {
|
||||
temp: string
|
||||
mode: 'read-only' | 'workspace-write'
|
||||
writeSid: string | undefined
|
||||
tempWriteSid: string | undefined
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
@@ -79,6 +77,7 @@ function parseArgs(raw: string[]): ParsedArgs {
|
||||
let temp: string | undefined
|
||||
let mode: string | undefined
|
||||
let writeSid: string | undefined
|
||||
let parsedTempWriteSid: string | undefined
|
||||
let index = 0
|
||||
for (; index < raw.length; index++) {
|
||||
const token = raw[index]
|
||||
@@ -94,6 +93,7 @@ function parseArgs(raw: string[]): ParsedArgs {
|
||||
case '--temp': temp = value; break
|
||||
case '--mode': mode = value; break
|
||||
case '--write-sid': writeSid = value; break
|
||||
case '--temp-write-sid': parsedTempWriteSid = value; break
|
||||
default: fail(`unknown argument: ${token}`)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function parseArgs(raw: string[]): ParsedArgs {
|
||||
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) }
|
||||
return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) }
|
||||
}
|
||||
|
||||
function requireDirectory(label: string, path: string): void {
|
||||
@@ -119,6 +119,14 @@ async function main(): Promise<number> {
|
||||
requireDirectory('--workspace', parsed.workspace)
|
||||
requireDirectory('--temp', parsed.temp)
|
||||
|
||||
const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined
|
||||
if (parsed.mode === 'read-only' && seamManaged) {
|
||||
fail('read-only does not accept --write-sid or --temp-write-sid')
|
||||
}
|
||||
if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) {
|
||||
fail('workspace-write requires --write-sid and --temp-write-sid together')
|
||||
}
|
||||
|
||||
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
|
||||
@@ -127,36 +135,46 @@ async function main(): Promise<number> {
|
||||
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()})`)
|
||||
}
|
||||
}
|
||||
|
||||
let ownedTempDir: string | undefined
|
||||
let sandbox: AclSandbox | undefined
|
||||
let initialized = false
|
||||
try {
|
||||
let privateTempDir: string | null = null
|
||||
let writeSid: string | undefined
|
||||
let privateTempSid: string | undefined
|
||||
if (parsed.mode === 'workspace-write') {
|
||||
writeSid = workspaceWriteSid(parsed.workspace)
|
||||
if (seamManaged) {
|
||||
if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace')
|
||||
privateTempDir = parsed.temp
|
||||
privateTempSid = tempWriteSid(privateTempDir)
|
||||
if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp')
|
||||
} else {
|
||||
ownedTempDir = mkdtempSync(join(parsed.temp, 'dsh-'))
|
||||
privateTempDir = ownedTempDir
|
||||
privateTempSid = tempWriteSid(privateTempDir)
|
||||
}
|
||||
}
|
||||
sandbox = new AclSandbox({
|
||||
writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [],
|
||||
tempDir: privateTempDir,
|
||||
mode: parsed.mode,
|
||||
...writeSid === undefined ? {} : { writeSid },
|
||||
...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid },
|
||||
manageDacls: !seamManaged,
|
||||
})
|
||||
await sandbox.init()
|
||||
initialized = true
|
||||
|
||||
if (privateTempDir !== null) {
|
||||
if (api.setEnvironmentVariableW('TMP', privateTempDir) === 0) {
|
||||
fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
if (api.setEnvironmentVariableW('TEMP', privateTempDir) === 0) {
|
||||
fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`)
|
||||
}
|
||||
}
|
||||
|
||||
const child = sandbox.spawn({
|
||||
command: parsed.command,
|
||||
args: parsed.args,
|
||||
@@ -166,10 +184,19 @@ async function main(): Promise<number> {
|
||||
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`)
|
||||
if (initialized) {
|
||||
try {
|
||||
sandbox?.dispose()
|
||||
} catch (error) {
|
||||
process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
}
|
||||
}
|
||||
if (ownedTempDir !== undefined) {
|
||||
try {
|
||||
rmSync(ownedTempDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +162,11 @@ export interface RestrictingSidSet {
|
||||
* 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]
|
||||
* - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID]
|
||||
*
|
||||
* 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
|
||||
* pwsh crashes 0xE0434352) fails without them. The write SIDs join 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
|
||||
@@ -186,24 +186,25 @@ export interface RestrictingSidSet {
|
||||
* @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 writeSids - the distinct write SIDs forming the workspace and
|
||||
* optional temp allowlists (workspace-write only; empty 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).
|
||||
* @param mode - selects the restricting list (workspace-write adds the capability SIDs).
|
||||
* @returns the restricted token handle.
|
||||
*/
|
||||
export function createRestrictedToken(
|
||||
api: Win32Bindings,
|
||||
currentToken: NativePtr,
|
||||
logonSid: NativePtr,
|
||||
writeSid: NativePtr | undefined,
|
||||
writeSids: readonly NativePtr[],
|
||||
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])
|
||||
: writeSids.length === 0
|
||||
? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires at least one write SID') })()
|
||||
: [logonSid, known.world, ...writeSids])
|
||||
const tokenSlot = allocPtrSlot()
|
||||
const created = api.createRestrictedToken(
|
||||
currentToken,
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* 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).
|
||||
* string itself is not a secret. Temporary directories use a separate,
|
||||
* per-directory identity from {@link tempWriteSid}; sharing the workspace
|
||||
* identity with temp would let sibling sessions write one another's temp
|
||||
* trees.
|
||||
*
|
||||
* The input MUST be the canonical workspace path (`realpathSync.native` on
|
||||
* Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it):
|
||||
@@ -36,3 +38,17 @@ export function workspaceWriteSid(workspaceRoot: string): string {
|
||||
const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1
|
||||
return `S-1-4-${first}-${second}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive one private temp directory's write SID. The random directory path
|
||||
* is the capability identity; a fixed third subauthority domain-separates
|
||||
* the result from every two-subauthority workspace SID.
|
||||
* @param tempDir - the private temp directory's absolute path.
|
||||
* @returns the SDDL string form.
|
||||
*/
|
||||
export function tempWriteSid(tempDir: string): string {
|
||||
const digest = createHash('sha256').update('temp\0', 'utf8').update(tempDir, '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}-1`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user