feat(sandbox): derive the windows-acl write SID per workspace, not per session
The per-session random write SID forced a full tree propagation per session per server lifetime (minutes on large workspaces). The write SID is now the per-workspace identity derived from the canonical workspace path (workspaceWriteSid: sha256 -> S-1-4-x-y), stored nowhere: the workspace-root ACE materializes once per workspace per machine and every later provision hits the exact-ACE skip. - workspace ACEs are STANDING (never revoked - the reuse cache); temp ACEs stay revocable (disposed with the provider), so an inheritable ACE never outlives its session's temp dir on the ambient temp root - AclSandbox requires the write SID under workspace-write; read-only parses/grants nothing; the runner derives the SID itself (the --write-sid flag's presence still marks the seam-managed contract) - the acl-session record drops writeSid (sessionId/workspace/tempDir remain): the SID-tamper surface and its validation are gone - sandbox-local holds two grant maps: standing workspace grants and revocable per-session temp grants Docs (README pair, design note pair, catalogs, type-equiv) and the acl-session/grant/acl/probe/runner suites updated; workspace-sid.spec pins the derivation contract.
This commit is contained in:
@@ -1,18 +1,24 @@
|
||||
/**
|
||||
* The windows-acl per-session write identity — the DURABLE half of the seam's
|
||||
* per-session grant reuse. Each session owns exactly one record (one orphan
|
||||
* write SID, one private temp subdirectory), stored as a log-only
|
||||
* The windows-acl session write record — the DURABLE half of the seam's
|
||||
* grant lifecycle. Each session owns exactly one record (its workspace
|
||||
* binding plus one private temp subdirectory), stored as a log-only
|
||||
* `sandbox/acl-session` event on the session log (the `sandbox/mode`
|
||||
* precedent): replayable, never in the model transcript, and no external
|
||||
* config store. The ACE half is server-lifetime state owned by the provider
|
||||
* ({@link AclWriteGrant} materialization, revoked on dispose); the record
|
||||
* survives restarts so a resumed session reuses the SAME SID — re-granting
|
||||
* idempotently merges into (or skips) the standing ACEs instead of leaking a
|
||||
* fresh dead SID's ACEs per restart. The record is BOUND to its owning
|
||||
* session id, so a fork (which copies the parent's events, record included)
|
||||
* never inherits the parent's identity — it provisions a fresh one. The
|
||||
* record's payload is durable input and is validated at the fold (orphan-SID
|
||||
* shape, well-formed temp path); a matching-but-tampered record fails loud.
|
||||
* config store. The record carries NO SID: the write SID is the
|
||||
* per-WORKSPACE identity derived from the workspace path
|
||||
* (`workspaceWriteSid`) — deterministic across sessions and server
|
||||
* restarts, so the workspace-root ACE materializes once per workspace per
|
||||
* machine (the grant's exact-ACE skip makes every later provision O(1))
|
||||
* instead of once per session. The ACE half is server-lifetime state owned
|
||||
* by the provider ({@link AclWriteGrant}: workspace ACEs standing, temp ACEs
|
||||
* revocable); the record survives restarts so a resumed session reuses the
|
||||
* SAME private temp subdirectory and the same derived SID — re-granting
|
||||
* idempotently merges into (or skips) the standing ACEs. The record is
|
||||
* BOUND to its owning session id, so a fork (which copies the parent's
|
||||
* events, record included) never inherits the parent's temp identity — it
|
||||
* provisions a fresh one. The record's payload is durable input and is
|
||||
* validated at the fold (well-formed workspace/temp paths); a
|
||||
* matching-but-tampered record fails loud.
|
||||
*
|
||||
* @module dsh-sandbox-local/acl-session
|
||||
*/
|
||||
@@ -20,22 +26,21 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { randomWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* The session's windows-acl write identity was provisioned — log-only
|
||||
* The session's windows-acl write record was provisioned — log-only
|
||||
* (like `sandbox/mode`; NOT a surface event, carries no `surfaceOp`):
|
||||
* durable and replayable, never in the model transcript. The LAST such
|
||||
* event owned by the session is its record ({@link sessionAclRecord});
|
||||
* the provider appends exactly one on the session's first Windows
|
||||
* confined execution.
|
||||
* confined execution. The write SID itself is NOT stored — it is the
|
||||
* per-workspace identity derived from `workspace`
|
||||
* (`workspaceWriteSid`).
|
||||
*/
|
||||
'sandbox/acl-session': {
|
||||
/** The orphan write SID (`S-1-4-x-y`) whose ACEs form the session's write allowlist. */
|
||||
writeSid: string
|
||||
/** The owning session — the binding a fork's copied event cannot satisfy. */
|
||||
sessionId: SessionId
|
||||
/** The workspace root the grant applies to (the session's immutable cwd, as resolved). */
|
||||
@@ -48,24 +53,19 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
|
||||
/** The durable per-session record carried by one `sandbox/acl-session` event. */
|
||||
export interface AclSessionRecord {
|
||||
/** The orphan write SID whose ACEs form the session's write allowlist. */
|
||||
writeSid: string
|
||||
/** The owning session id (binds the record against fork inheritance). */
|
||||
sessionId: SessionId
|
||||
/** The workspace root the record was provisioned for. */
|
||||
/** The workspace root the record was provisioned for (the write SID derives from it). */
|
||||
workspace: string
|
||||
/** The session's private temp subdirectory. */
|
||||
tempDir: string
|
||||
}
|
||||
|
||||
/** Orphan shape `S-1-4-x-y` — a replayed `Everyone` SID would widen the grant to every token. */
|
||||
const ORPHAN_SID_PATTERN = /^S-1-4-\d+-\d+$/u
|
||||
|
||||
/**
|
||||
* The session's record: the last `sandbox/acl-session` event owned by it, or
|
||||
* undefined (never confined / a fork). Durable-input validation: tampered
|
||||
* SID or temp path fails loud. @param events/@param sessionId/@returns as
|
||||
* below.
|
||||
* workspace or temp path fails loud. @param events/@param sessionId/@returns
|
||||
* as below.
|
||||
* @param events - session events (other types skipped).
|
||||
* @param sessionId - owning session (fork binding).
|
||||
* @returns the last owned record, or undefined without one.
|
||||
@@ -77,12 +77,6 @@ export function sessionAclRecord(events: readonly SessionEvent[], sessionId: Ses
|
||||
const data = event.data
|
||||
// Fork copies the parent's record: skip non-owned records (fork mints fresh).
|
||||
if (data.sessionId !== sessionId) continue
|
||||
if (typeof data.writeSid !== 'string' || !ORPHAN_SID_PATTERN.test(data.writeSid)) {
|
||||
throw new Error(
|
||||
`sandbox-local: session "${sessionId}" acl record carries a malformed write SID ${JSON.stringify(data.writeSid)} `
|
||||
+ '(expected the orphan shape S-1-4-x-y)',
|
||||
)
|
||||
}
|
||||
if (typeof data.workspace !== 'string' || data.workspace.length === 0) {
|
||||
throw new Error(`sandbox-local: session "${sessionId}" acl record carries an empty workspace`)
|
||||
}
|
||||
@@ -112,18 +106,17 @@ export function sessionTempDir(): string {
|
||||
|
||||
/**
|
||||
* Provision the record for a session that has none (its first Windows
|
||||
* confined execution): a fresh write SID plus the private temp subdirectory,
|
||||
* appended as exactly one log-only `sandbox/acl-session` event — the
|
||||
* provision IS its event, nothing mutates record state out of band. Fork
|
||||
* (whose copied parent record is not its own) provisions a fresh record;
|
||||
* resume replays the stored one.
|
||||
* confined execution): the workspace binding plus the private temp
|
||||
* subdirectory, appended as exactly one log-only `sandbox/acl-session`
|
||||
* event — the provision IS its event, nothing mutates record state out of
|
||||
* band. Fork (whose copied parent record is not its own) provisions a fresh
|
||||
* record; resume replays the stored one.
|
||||
* @param session - the session the record belongs to.
|
||||
* @param workspaceRoot - the resolved policy root (the session's immutable cwd).
|
||||
* @returns the provisioned record.
|
||||
*/
|
||||
export function provisionAclSession(session: Session, workspaceRoot: string): AclSessionRecord {
|
||||
const record: AclSessionRecord = {
|
||||
writeSid: randomWriteSid(),
|
||||
sessionId: session.id,
|
||||
workspace: workspaceRoot,
|
||||
tempDir: sessionTempDir(),
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
* classification facts. Missing or unusable confinement fails closed rather
|
||||
* than returning the original argv.
|
||||
*
|
||||
* The windows-acl rung additionally owns the per-session write grant: one
|
||||
* orphan write SID and one private temp subdirectory per session (durable
|
||||
* record in the session log — see `./acl-session.ts`), ACEs materialized
|
||||
* lazily at the session's first confined execution and held for the SERVER
|
||||
* process's lifetime (revoked on dispose). The runner receives `--write-sid`
|
||||
* and stops managing DACLs itself.
|
||||
* 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 one private temp subdirectory per session
|
||||
* (durable record in the session log — see `./acl-session.ts`). 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
|
||||
*/
|
||||
|
||||
@@ -30,7 +34,7 @@ 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 } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl'
|
||||
import { provisionAclSession, sessionAclRecord } from './acl-session.ts'
|
||||
import type { AclSessionRecord } from './acl-session.ts'
|
||||
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
|
||||
@@ -227,9 +231,10 @@ const RUNNER_FAILURE_RULES = {
|
||||
|
||||
/**
|
||||
* Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the
|
||||
* chain verdict and, on the windows-acl rung, the per-session write grants
|
||||
* ({@link AclWriteGrant}, one per session, revoked on provider dispose); the
|
||||
* one-time probes spawn nothing else.
|
||||
* 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.
|
||||
@@ -248,11 +253,15 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
/** Cached chain verdict; undefined until the first confined wrap needs it. */
|
||||
private selectedRunner: SelectedRunner | 'unavailable' | undefined
|
||||
/**
|
||||
* Server-lifetime per-session write grants (windows-acl rung), keyed by the
|
||||
* session's orphan write SID — the native half of the per-session reuse;
|
||||
* the durable half lives in the session log (`./acl-session.ts`).
|
||||
* 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); the
|
||||
* durable half (workspace binding + private temp dir) lives in the
|
||||
* session log (`./acl-session.ts`).
|
||||
*/
|
||||
private readonly aclGrants = new Map<string, AclWriteGrant>()
|
||||
private readonly workspaceGrants = new Map<string, AclWriteGrant>()
|
||||
private readonly tempGrants = new Map<string, AclWriteGrant>()
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
@@ -274,9 +283,10 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
this.configuredRunnerFailureSignatures = runnerFailureSignatures
|
||||
this.probeTimeoutMs = config.probeTimeoutMs as number
|
||||
assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs)
|
||||
// Standing ACL grants are revoked with the provider: a clean server
|
||||
// shutdown leaves no orphan-SID ACEs behind (an unclean one leaves ACEs
|
||||
// the session's durable record re-grants idempotently on resume).
|
||||
// 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()
|
||||
})
|
||||
@@ -357,10 +367,11 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
// 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.
|
||||
// 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' ? record.tempDir : tmpdir(),
|
||||
'--mode', policy.mode,
|
||||
'--write-sid', record.writeSid,
|
||||
'--write-sid', workspaceWriteSid(record.workspace),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -399,8 +410,8 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
// Immediate durability kick: the append is write-behind (bounded
|
||||
// coordinator window); flush now so the record is durable as close to
|
||||
// its ACE materialization as the synchronous confine seam allows. The
|
||||
// residual window (a crash inside the flush latency) can strand inert
|
||||
// orphan-SID ACEs — documented in the README.
|
||||
// residual window (a crash inside the flush latency) strands the
|
||||
// private temp directory unrecorded — documented in the README.
|
||||
void store.flush(session)
|
||||
return record
|
||||
}
|
||||
@@ -408,29 +419,49 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
/**
|
||||
* Materialize the record's ACEs once per server lifetime: lazily at the
|
||||
* session's first confined execution, reused for every later call (the map
|
||||
* hit is the whole call). Workspace-write grants the workspace root and
|
||||
* the private temp subdirectory — created here EXCLUSIVELY (the name is
|
||||
* random and unguessable, a pre-existing entry throws EEXIST, and a
|
||||
* reparse point is rejected, so the grant never lands on an
|
||||
* attacker-placed object); read-only materializes NOTHING — its token
|
||||
* alone restricts every write, and a standing grant from an earlier
|
||||
* workspace-write period is KEPT through a downgrade (never revoked): the
|
||||
* read-only restricted token carries no orphan 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 grant is revoked
|
||||
* before the error propagates.
|
||||
* hits are the whole call). The write SID is the per-workspace identity
|
||||
* derived from the record's workspace. Workspace-write grants the
|
||||
* workspace root STANDING (the ACE outlives every session — the reuse
|
||||
* cache) and the private temp subdirectory REVOCABLY — created here
|
||||
* EXCLUSIVELY (the name is random and unguessable, a pre-existing entry
|
||||
* throws EEXIST, and a reparse point is rejected, so the grant never
|
||||
* lands on an attacker-placed 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 record - the session's durable record.
|
||||
* @param mode - the policy mode (grants exist only under workspace-write).
|
||||
*/
|
||||
private materializeAclGrant(record: AclSessionRecord, mode: ConfinedSandboxMode): void {
|
||||
if (this.aclGrants.has(record.writeSid) || mode === 'read-only') return
|
||||
const grant = AclWriteGrant.create(record.writeSid)
|
||||
if (mode === 'read-only') return
|
||||
const writeSid = workspaceWriteSid(record.workspace)
|
||||
if (!this.workspaceGrants.has(record.workspace)) {
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
try {
|
||||
grant.add(record.workspace, 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(record.workspace, grant)
|
||||
}
|
||||
if (this.tempGrants.has(record.sessionId)) return
|
||||
const grant = AclWriteGrant.create(writeSid)
|
||||
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(record.tempDir)
|
||||
grant.add(record.workspace)
|
||||
grant.add(record.tempDir)
|
||||
} catch (error) {
|
||||
// Revoke whatever stands and free the SID — never leave a half-grant
|
||||
@@ -438,30 +469,32 @@ export class LocalSandboxProvider extends SandboxProvider {
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl grant materialization failed and its cleanup also failed')
|
||||
throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.aclGrants.set(record.writeSid, grant)
|
||||
this.tempGrants.set(record.sessionId, grant)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke every standing per-session grant and free every SID (provider
|
||||
* dispose). Cleanup failures are reported, not thrown: cordis teardown
|
||||
* must not be aborted by grant revocation, and the durable records make a
|
||||
* missed revocation self-healing on the next resume.
|
||||
* Dispose every write grant (provider dispose): the revocable temp ACEs
|
||||
* are revoked and every SID allocation freed; the standing workspace ACEs
|
||||
* stay (the reuse cache). Cleanup failures are reported, not thrown:
|
||||
* cordis teardown must not be aborted by grant cleanup, and the durable
|
||||
* records make a missed revocation self-healing on the next resume.
|
||||
*/
|
||||
private revokeAclGrants(): void {
|
||||
if (this.aclGrants.size === 0) return
|
||||
if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return
|
||||
const failures: unknown[] = []
|
||||
for (const grant of this.aclGrants.values()) {
|
||||
for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) {
|
||||
try {
|
||||
grant.dispose()
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
this.aclGrants.clear()
|
||||
this.workspaceGrants.clear()
|
||||
this.tempGrants.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)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* windows-acl per-session grant: the DURABLE record (log-event fold/provision
|
||||
* with ownership binding + tamper validation) plus the SERVER-LIFETIME ACE
|
||||
* materialization, through the REAL LocalSandboxProvider.confine() with a
|
||||
* real session store. Win32 surface mocked at the package boundary; the
|
||||
* real-FFI grant behavior lives in sandbox-windows-acl's win32 tests.
|
||||
* windows-acl write grants: the DURABLE record (log-event fold/provision with
|
||||
* ownership binding + tamper validation) plus the SERVER-LIFETIME ACE
|
||||
* materialization (standing workspace grant per workspace, revocable temp
|
||||
* grant per session), through the REAL LocalSandboxProvider.confine() with a
|
||||
* real session store. 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'
|
||||
@@ -19,7 +21,7 @@ import { sessionTempDir } from '../src/acl-session.ts'
|
||||
|
||||
/** Cross-file state shared with the vi.mock factory (hoisting contract). */
|
||||
const mockState = vi.hoisted(() => ({
|
||||
grants: [] as Array<{ writeSid: string; added: string[]; disposed: boolean }>,
|
||||
grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>,
|
||||
addFailure: undefined as Error | undefined,
|
||||
disposeFailure: undefined as Error | undefined,
|
||||
}))
|
||||
@@ -27,7 +29,7 @@ const mockState = vi.hoisted(() => ({
|
||||
vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
|
||||
class MockAclWriteGrant {
|
||||
readonly writeSid: string
|
||||
readonly added: string[] = []
|
||||
readonly added: Array<{ path: string; standing: boolean }> = []
|
||||
disposed = false
|
||||
constructor(writeSid: string) {
|
||||
this.writeSid = writeSid
|
||||
@@ -36,20 +38,23 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => {
|
||||
static create(writeSid: string): MockAclWriteGrant {
|
||||
return new MockAclWriteGrant(writeSid)
|
||||
}
|
||||
add(path: string): void {
|
||||
add(path: string, standing = false): void {
|
||||
if (mockState.addFailure !== undefined) throw mockState.addFailure
|
||||
this.added.push(path)
|
||||
this.added.push({ path, standing })
|
||||
}
|
||||
dispose(): void {
|
||||
if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure
|
||||
this.disposed = true
|
||||
}
|
||||
}
|
||||
return { AclWriteGrant: MockAclWriteGrant, randomWriteSid: () => 'S-1-4-42-42' }
|
||||
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'
|
||||
|
||||
/** One provisioned record event, shaped like the live log's envelope. */
|
||||
function recordEvent(record: { writeSid: string; sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent {
|
||||
function recordEvent(record: { sessionId: SessionIdType; workspace: string; tempDir: string }): SessionEvent {
|
||||
return { type: 'sandbox/acl-session', seq: 0, time: 0, data: record }
|
||||
}
|
||||
|
||||
@@ -72,7 +77,7 @@ function shapedTempPath(): string {
|
||||
return join(tmpdir(), `dsh-${'ab'.repeat(8)}`)
|
||||
}
|
||||
|
||||
describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
describe('windows-acl write grants (LocalSandboxProvider)', () => {
|
||||
const scratch: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -85,7 +90,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
it('workspace-write: first confine provisions the record and materializes the grant ONCE (--write-sid + the private temp dir)', async () => {
|
||||
it('workspace-write: first confine provisions the record and materializes ONCE (standing workspace + revocable private temp)', async () => {
|
||||
try {
|
||||
const { ctx, sandbox, fiber } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
@@ -95,28 +100,40 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(confined.argv).toContain('--write-sid')
|
||||
expect(confined.argv).toContain('S-1-4-42-42')
|
||||
expect(confined.argv).toContain(DERIVED_SID)
|
||||
expect(confined.argv).toContain('workspace-write')
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
const tempDir = (session.events.at(-1)!.data as { tempDir: string }).tempDir
|
||||
scratch.push(tempDir)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, tempDir], disposed: false })
|
||||
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
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
|
||||
// Reuse: the second confine is the map hit.
|
||||
// Reuse: the second confine is the map hits.
|
||||
sandbox.confine(['pwsh', '/Command', 'x'], policy)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(session.events).toHaveLength(1)
|
||||
|
||||
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 same SID, the downgrade keeps the standing grant', async () => {
|
||||
it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
@@ -134,28 +151,33 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
scratch.push(record.tempDir)
|
||||
expect(existsSync(record.tempDir)).toBe(false)
|
||||
|
||||
// Upgrade: first workspace-write materializes with the same SID.
|
||||
// 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', record.tempDir,
|
||||
'--mode', 'workspace-write',
|
||||
'--write-sid', 'S-1-4-42-42',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42', added: [ws, record.tempDir], disposed: false })
|
||||
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: record.tempDir, standing: false }],
|
||||
disposed: false,
|
||||
})
|
||||
expect(existsSync(record.tempDir)).toBe(true)
|
||||
|
||||
// Reuse: map hit.
|
||||
// Reuse: map hits.
|
||||
sandbox.confine(['true'], workspaceWrite)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
// Downgrade: standing grant KEPT (inert under read-only, free re-upgrade).
|
||||
sandbox.confine(['true'], readOnly)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[0]!.disposed).toBe(false)
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
} finally {
|
||||
@@ -177,7 +199,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
'--workspace', ws,
|
||||
'--temp', tmpdir(), // NOT the private subdir: read-only grants nothing
|
||||
'--mode', 'read-only',
|
||||
'--write-sid', 'S-1-4-42-42',
|
||||
'--write-sid', DERIVED_SID,
|
||||
'--',
|
||||
'true',
|
||||
])
|
||||
@@ -188,12 +210,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resume: a seeded record replays with the SAME SID and no second event is appended', async () => {
|
||||
it('resume: a seeded record replays the same derived SID and temp dir with no second event appended', async () => {
|
||||
try {
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const tempDir = shapedTempPath()
|
||||
const record = { writeSid: 'S-1-4-77-1', sessionId: SessionId('resumed'), workspace: ws, tempDir }
|
||||
const record = { sessionId: SessionId('resumed'), workspace: ws, tempDir }
|
||||
scratch.push(tempDir)
|
||||
|
||||
const first = await setup()
|
||||
@@ -202,9 +224,9 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') }
|
||||
const confined = first.sandbox.confine(['true'], policy)
|
||||
expect(confined.argv).toContain('S-1-4-77-1')
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-77-1', added: [ws, tempDir] })
|
||||
expect(confined.argv).toContain(DERIVED_SID) // re-derived from the record's workspace
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[1]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: tempDir, standing: false }] })
|
||||
// Replay IS the state: nothing appended.
|
||||
expect(session.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(1)
|
||||
expect(session.events).toHaveLength(2)
|
||||
@@ -213,52 +235,52 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh identity (sessionId binding)', async () => {
|
||||
it('fork: a child seeded with the PARENT\'s events ignores the parent record and provisions a fresh temp identity (sessionId binding)', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const parentTemp = shapedTempPath()
|
||||
const parentRecord = { writeSid: 'S-1-4-77-9', sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp }
|
||||
const parentRecord = { sessionId: SessionId('parent'), workspace: ws, tempDir: parentTemp }
|
||||
scratch.push(parentTemp)
|
||||
// SessionStore.fork copies the parent's events verbatim — the child must NOT inherit the record.
|
||||
const child = ctx.sessions.create(SessionId('child'), { seed: [recordEvent(parentRecord)], meta: { cwd: ws } })
|
||||
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') }
|
||||
sandbox.confine(['true'], policy)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]).toMatchObject({ writeSid: 'S-1-4-42-42' }) // fresh, NOT the parent's
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
// Fresh temp identity, NOT the parent's (the workspace SID is shared by
|
||||
// derivation — the workspace is the same).
|
||||
const childTemp = (child.events.at(-1)!.data as { tempDir: string }).tempDir
|
||||
expect(childTemp).not.toBe(parentTemp)
|
||||
expect(mockState.grants[1]).toMatchObject({ added: [{ path: childTemp, standing: false }] })
|
||||
expect(child.events.filter(event => event.type === 'sandbox/acl-session')).toHaveLength(2) // parent's copied + child's fresh
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a matching-but-tampered record: non-orphan SID, foreign temp path, and non-string fields never materialize', async () => {
|
||||
it('fails loud on a matching-but-tampered record: foreign temp path, empty workspace, and non-string fields never materialize', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
|
||||
// writeSid = Everyone: would widen the grant to every token.
|
||||
const everyone = { writeSid: 'S-1-1-0', sessionId: SessionId('tampered-sid'), workspace: ws, tempDir: shapedTempPath() }
|
||||
ctx.sessions.create(SessionId('tampered-sid'), { seed: [recordEvent(everyone)], meta: { cwd: ws } })
|
||||
const sidPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-sid') }
|
||||
expect(() => sandbox.confine(['true'], sidPolicy)).toThrow(/malformed write SID/)
|
||||
|
||||
// tempDir outside the host temp root.
|
||||
const foreignTemp = { writeSid: 'S-1-4-42-7', sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' }
|
||||
const foreignTemp = { sessionId: SessionId('tampered-temp'), workspace: ws, tempDir: '/attacker/path' }
|
||||
ctx.sessions.create(SessionId('tampered-temp'), { seed: [recordEvent(foreignTemp)], meta: { cwd: ws } })
|
||||
const tempPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('tampered-temp') }
|
||||
expect(() => sandbox.confine(['true'], tempPolicy)).toThrow(/outside the host temp root/)
|
||||
|
||||
// Non-string durable fields (a corrupted/tampered JSONL payload): the
|
||||
// typeof guards fail loud before any string operation runs.
|
||||
// typeof guards fail loud before any string operation runs. There is
|
||||
// NO stored SID to tamper with — the write SID is derived from the
|
||||
// workspace path, so the old "SID rewritten to Everyone" attack
|
||||
// surface does not exist.
|
||||
const cases: Array<{ id: string; record: Record<string, unknown>; expect: RegExp }> = [
|
||||
{ id: 'tampered-type-sid', record: { writeSid: 42, sessionId: SessionId('tampered-type-sid'), workspace: ws, tempDir: shapedTempPath() }, expect: /malformed write SID/ },
|
||||
{ id: 'tampered-type-ws-null', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ },
|
||||
{ id: 'tampered-type-ws-empty', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ },
|
||||
{ id: 'tampered-type-temp', record: { writeSid: 'S-1-4-42-6', sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ },
|
||||
{ id: 'tampered-type-ws-null', record: { sessionId: SessionId('tampered-type-ws-null'), workspace: null, tempDir: shapedTempPath() }, expect: /empty workspace/ },
|
||||
{ id: 'tampered-type-ws-empty', record: { sessionId: SessionId('tampered-type-ws-empty'), workspace: '', tempDir: shapedTempPath() }, expect: /empty workspace/ },
|
||||
{ id: 'tampered-type-temp', record: { sessionId: SessionId('tampered-type-temp'), workspace: ws, tempDir: 123 }, expect: /outside the host temp root/ },
|
||||
]
|
||||
for (const c of cases) {
|
||||
ctx.sessions.create(SessionId(c.id), { seed: [recordEvent(c.record as never)], meta: { cwd: ws } })
|
||||
@@ -271,7 +293,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving grants', async () => {
|
||||
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 { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
@@ -281,12 +303,15 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
const preexisting = shapedTempPath()
|
||||
mkdirSync(preexisting)
|
||||
scratch.push(preexisting)
|
||||
const preRecord = { writeSid: 'S-1-4-42-8', sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting }
|
||||
const preRecord = { sessionId: SessionId('preexisting'), workspace: ws, tempDir: preexisting }
|
||||
ctx.sessions.create(SessionId('preexisting'), { seed: [recordEvent(preRecord)], meta: { cwd: ws } })
|
||||
const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') }
|
||||
expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants[0]!.disposed).toBe(true) // self-revoked
|
||||
// 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-'))
|
||||
@@ -294,12 +319,12 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
const linkPath = shapedTempPath().replace(/abab$/, 'cdcd') // distinct well-shaped name
|
||||
symlinkSync(target, linkPath)
|
||||
scratch.push(linkPath)
|
||||
const linkRecord = { writeSid: 'S-1-4-42-9', sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath }
|
||||
const linkRecord = { sessionId: SessionId('reparse'), workspace: ws, tempDir: linkPath }
|
||||
ctx.sessions.create(SessionId('reparse'), { seed: [recordEvent(linkRecord)], meta: { cwd: ws } })
|
||||
const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') }
|
||||
expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
expect(mockState.grants[1]!.disposed).toBe(true)
|
||||
expect(mockState.grants).toHaveLength(4)
|
||||
expect(mockState.grants[3]!.disposed).toBe(true)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
@@ -310,7 +335,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
scratch.push(ws)
|
||||
const mismatched = { writeSid: 'S-1-4-77-2', sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() }
|
||||
const mismatched = { sessionId: SessionId('stale'), workspace: '/somewhere-else', tempDir: shapedTempPath() }
|
||||
ctx.sessions.create(SessionId('stale'), { seed: [recordEvent(mismatched)], meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('stale') }
|
||||
expect(() => sandbox.confine(['true'], policy)).toThrow(/does not match the resolved policy root/)
|
||||
@@ -336,7 +361,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a grant failure mid-materialization revokes what was granted and rethrows (AggregateError when the cleanup also fails)', async () => {
|
||||
it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => {
|
||||
try {
|
||||
const { ctx, sandbox } = await setup()
|
||||
const ws = workspaceRoot()
|
||||
@@ -344,7 +369,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
const session = ctx.sessions.create(SessionId('sess-add-fail'), { meta: { cwd: ws } })
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') }
|
||||
|
||||
// add() throws on the FIRST path: cleanup dispose() runs, original error propagates.
|
||||
// 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')
|
||||
scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir)
|
||||
@@ -381,7 +406,7 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('a failing revoke at provider dispose is reported via ctx.logger.warn and never thrown into teardown', async () => {
|
||||
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()
|
||||
@@ -390,12 +415,13 @@ describe('windows-acl per-session grant (LocalSandboxProvider)', () => {
|
||||
const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') }
|
||||
sandbox.confine(['true'], policy)
|
||||
scratch.push((session.events.at(-1)!.data as { tempDir: string }).tempDir)
|
||||
expect(mockState.grants).toHaveLength(1)
|
||||
expect(mockState.grants).toHaveLength(2)
|
||||
|
||||
mockState.disposeFailure = new Error('revoke exploded')
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
await fiber.dispose()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure'))
|
||||
// BOTH grants (standing workspace + revocable temp) fail their dispose.
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failures'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' }))
|
||||
} finally {
|
||||
cleanup()
|
||||
|
||||
Reference in New Issue
Block a user