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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user