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
@@ -17,6 +17,7 @@ import koffi from 'koffi'
import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts'
import { AclSandbox } from '../src/index.ts'
import { createRestrictedToken } from '../src/token.ts'
import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts'
import type { NativePtr, Win32Bindings } from '../src/ffi.ts'
import * as abi from '../src/win32-abi.ts'
@@ -170,18 +171,42 @@ describe.skipIf(!isWin32)('ACL editing', () => {
}
})
it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves neither ACE', async () => {
it('interleaved sandbox instances: A.init → B.init → A.dispose → B.dispose leaves BOTH standing workspace ACEs (the per-workspace reuse cache)', async () => {
const api = await win32()
const dir = scratch()
const sandboxA = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' })
const sandboxB = new AclSandbox({ writableDirs: [dir], tempDir: null, writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })
await sandboxA.init()
await sandboxB.init()
// Workspace ACEs are STANDING: dispose frees the instance's SID
// allocations but deliberately leaves the ACEs — they are the reuse
// cache the next provision's exact-ACE skip consumes.
sandboxA.dispose()
sandboxB.dispose()
const aces = readDirectAces(api, dir)
expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(false)
expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(false)
expect(aces.some(ace => ace.sid === 'S-1-4-9000-1')).toBe(true)
expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true)
})
it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => {
const api = await win32()
const workspaceDir = scratch()
const tempDir = scratch()
const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' })
await sandbox.init()
sandbox.dispose()
const workspaceAces = readDirectAces(api, workspaceDir)
expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true)
const tempAces = readDirectAces(api, tempDir)
expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false)
})
it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => {
const dir = scratch()
expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' }))
.toThrow(/requires a write SID/)
expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write'))
.toThrow(/requires the write SID/)
})
it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => {