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:
Huanqi Cao
2026-08-09 10:44:35 +08:00
parent d18dc76b89
commit 5fea4b7c4b
24 changed files with 543 additions and 330 deletions
@@ -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()