fix(sandbox): isolate Windows temp capabilities

This commit is contained in:
Tianyi Cui
2026-08-10 15:31:38 +08:00
parent 9a3c89d04a
commit fd650af340
30 changed files with 744 additions and 596 deletions

View File

@@ -192,21 +192,38 @@ describe.skipIf(!isWin32)('ACL editing', () => {
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' })
const sandbox = new AclSandbox({
writableDirs: [workspaceDir],
tempDir,
writeSid: 'S-1-4-9000-3',
tempWriteSid: 'S-1-4-9000-3-1',
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)
expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).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/)
expect(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' }))
.toThrow(/requires an explicit private temp directory or null/)
expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' }))
.toThrow(/requires a temp write SID/)
expect(() => new AclSandbox({
writableDirs: [dir],
tempDir: dir,
writeSid: 'S-1-4-1-1',
tempWriteSid: 'S-1-4-1-1',
mode: 'workspace-write',
})).toThrow(/must be distinct/)
expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write'))
.toThrow(/requires at least one write SID/)
})
it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => {

View File

@@ -6,10 +6,10 @@
* WRITE_RESTRICTED token intersects write accesses only.
*
* The escape target sits in its own scratch dir under the system temp
* directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never
* defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the
* whole real temp tree) and the writable dir is a separate mkdtemp directory
* that contains neither sibling. Nothing under the user profile is touched.
* directory, OUTSIDE both granted trees: tempDir is an explicit private
* mkdtemp directory (the API never grants the ambient temp root implicitly),
* and the writable dir is a separate mkdtemp directory that contains neither
* sibling. Nothing under the user profile is touched.
*/
import { execFileSync } from 'node:child_process'
@@ -47,11 +47,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', ()
secretFile = join(scratchRoot, 'secret.txt')
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
escapeFile = join(scratchRoot, 'escaped.txt')
// tempDir is passed explicitly: GetTempPathW reads the native environment
// block, which host runtimes (vitest worker pools) may not keep in sync
// with process.env — and a real-temp grant would inherit over every
// temp subdirectory, including this test's scratch dir.
sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' })
// The direct API requires this explicit private temp directory and its
// own SID; it never widens the grant over the ambient temp root.
sandbox = new AclSandbox({
writableDirs: [writableDir],
tempDir: isolatedTemp,
writeSid: 'S-1-4-9000-4',
tempWriteSid: 'S-1-4-9000-4-1',
mode: 'workspace-write',
})
await sandbox.init()
})
@@ -91,7 +95,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', ()
it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => {
// A malformed SID makes ConvertStringSidToSidW fail; init must throw
// before any grant is applied and never spawn unrestricted.
const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' })
await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u)
}, 15_000)
})

View File

@@ -25,7 +25,7 @@ async function setup(internals: LocalSandboxProvider['internals']) {
}
describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
it('workspace-write: runner argv prefix, explicit temp, mode flag, partial enforcement, ACL denial dialect', async () => {
it('agentless workspace-write: runner argv prefix, temp root, mode flag, partial enforcement, ACL denial dialect', async () => {
const probeWindowsAcl = vi.fn(() => true)
const sandbox = await setup({
platform: 'win32',

View File

@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
import { AclWriteGrant } from '../src/index.ts'
import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts'
const isWin32 = process.platform === 'win32'
const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
@@ -77,12 +77,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
it('workspace-write: the confined child writes granted directories only', () => {
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
// The restricted token puts pwsh into ConstrainedLanguage in BOTH modes
// (documented Known Limitation) — pinned here so a token change that
// silently restores FullLanguage is caught.
// The private-temp capability lets PowerShell complete its startup
// AppLocker probe, so without a host policy workspace-write stays in
// FullLanguage. Read-only cannot create those scratch files and fails
// that probe closed to ConstrainedLanguage (pinned below).
'\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;',
`try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
`try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
"try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};",
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`,
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
// Authenticated Users is absent from BOTH lists: the WMI namespace
@@ -96,7 +97,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
])
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage')
expect(result.stdout).toContain('LANGMODE: FullLanguage')
expect(result.stdout).toContain('TARGET-WRITE: OK')
expect(result.stdout).toContain('TEMP-WRITE: OK')
expect(result.stdout).toContain('ESCAPE-WRITE: DENIED')
@@ -163,33 +164,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(existsSync(renamedDir)).toBe(true)
}, 30_000)
it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => {
const writeSid = 'S-1-4-9000-99'
it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => {
const seamWorkspace = join(scratchRoot, 'seam-workspace')
mkdirSync(seamWorkspace)
const writeSid = workspaceWriteSid(seamWorkspace)
const privateTemp = join(isolatedTemp, 'private-subdir')
mkdirSync(privateTemp)
const grant = AclWriteGrant.create(writeSid)
const privateTempSid = tempWriteSid(privateTemp)
const grant = AclWriteGrant.create(privateTempSid)
grant.add(privateTemp)
try {
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
`try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`,
`try{Set-Content -Path '${seamWorkspace}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`,
`try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`,
"'TEMP-ENV: ' + $env:TEMP;",
"'TMP-ENV: ' + $env:TMP",
].join('')
const result = runRunner([
'--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
'--workspace', seamWorkspace, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
'--temp-write-sid', privateTempSid,
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
])
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
// The runner granted nothing (only the caller's private-temp grant
// The runner granted nothing (only the caller's temp-SID grant
// stands): the workspace write is denied, the private temp write lands,
// and the child's TMP/TEMP point at the private subdirectory.
expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED')
expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK')
expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`)
expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`)
expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false)
expect(existsSync(join(seamWorkspace, 'server-granted.txt'))).toBe(false)
expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true)
} finally {
grant.dispose()
@@ -197,6 +202,81 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
}
}, 30_000)
it('temp capabilities isolate sibling sessions that share one workspace SID', () => {
const writeSid = workspaceWriteSid(writableDir)
const tempA = join(isolatedTemp, 'session-a')
const tempB = join(isolatedTemp, 'session-b')
mkdirSync(tempA)
mkdirSync(tempB)
const sidA = tempWriteSid(tempA)
const sidB = tempWriteSid(tempB)
const workspaceGrant = AclWriteGrant.create(writeSid)
const grantA = AclWriteGrant.create(sidA)
const grantB = AclWriteGrant.create(sidB)
workspaceGrant.add(writableDir)
grantA.add(tempA)
grantB.add(tempB)
const sharedWorkspaceFile = join(writableDir, 'shared-between-sessions.txt')
const probe = [
"const fs = require('node:fs');",
"const targets = [['OWN', process.argv[1]], ['SIBLING', process.argv[2]], ['WORKSPACE', process.argv[3]]];",
"if (process.argv[4]) targets.push(['SIBLING-EXISTING', process.argv[4]]);",
'for (const [name, target] of targets) {',
"try { fs.writeFileSync(target, name); console.log(name + ': OK'); } catch { console.log(name + ': DENIED'); }",
'}',
].join('')
try {
const resultA = runRunner([
'--workspace', writableDir, '--temp', tempA, '--mode', 'workspace-write',
'--write-sid', writeSid, '--temp-write-sid', sidA,
'--', process.execPath, '-e', probe, join(tempA, 'a.txt'), join(tempB, 'a-escaped.txt'), sharedWorkspaceFile,
])
expect(resultA.status, `stderr: ${resultA.stderr}`).toBe(0)
expect(resultA.stdout).toContain('OWN: OK')
expect(resultA.stdout).toContain('SIBLING: DENIED')
expect(resultA.stdout).toContain('WORKSPACE: OK')
const resultB = runRunner([
'--workspace', writableDir, '--temp', tempB, '--mode', 'workspace-write',
'--write-sid', writeSid, '--temp-write-sid', sidB,
'--', process.execPath, '-e', probe, join(tempB, 'b.txt'), join(tempA, 'b-escaped.txt'), sharedWorkspaceFile, join(tempA, 'a.txt'),
])
expect(resultB.status, `stderr: ${resultB.stderr}`).toBe(0)
expect(resultB.stdout).toContain('OWN: OK')
expect(resultB.stdout).toContain('SIBLING: DENIED')
expect(resultB.stdout).toContain('SIBLING-EXISTING: DENIED')
expect(resultB.stdout).toContain('WORKSPACE: OK')
expect(existsSync(join(tempB, 'a-escaped.txt'))).toBe(false)
expect(existsSync(join(tempA, 'b-escaped.txt'))).toBe(false)
expect(readFileSync(join(tempA, 'a.txt'), 'utf8')).toBe('OWN')
} finally {
workspaceGrant.dispose()
grantA.dispose()
grantB.dispose()
rmSync(tempA, { recursive: true, force: true })
rmSync(tempB, { recursive: true, force: true })
}
}, 30_000)
it('agentless workspace-write creates a fresh private temp per call and removes it on exit', () => {
const captureA = join(writableDir, 'agentless-temp-a.txt')
const captureB = join(writableDir, 'agentless-temp-b.txt')
for (const capture of [captureA, captureB]) {
const result = runRunner([
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], process.env.TEMP)", capture,
])
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
}
const tempA = readFileSync(captureA, 'utf8')
const tempB = readFileSync(captureB, 'utf8')
expect(tempA).not.toBe(tempB)
expect(tempA.startsWith(isolatedTemp)).toBe(true)
expect(tempB.startsWith(isolatedTemp)).toBe(true)
expect(existsSync(tempA)).toBe(false)
expect(existsSync(tempB)).toBe(false)
}, 30_000)
it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => {
// Two-layer pin of the grandchild-spawn boundary:
// - the token default DACL carries a restricting-SID ACE (set in init),
@@ -238,7 +318,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
// it, so the workspace write is denied (previously it LEAKED). The
// switch back reuses the SAME standing ACE: the re-upgrade write lands
// without any re-grant.
const writeSid = 'S-1-4-9001-7'
const writeSid = workspaceWriteSid(writableDir)
const privateTemp = join(isolatedTemp, 'mode-switch-temp')
mkdirSync(privateTemp)
const privateTempSid = tempWriteSid(privateTemp)
const grant = AclWriteGrant.create(writeSid)
grant.add(writableDir)
try {
@@ -247,7 +330,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
`try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`,
].join('')
const downgraded = runRunner([
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid,
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only',
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe,
])
expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0)
@@ -259,7 +342,8 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
`try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`,
].join('')
const reupgraded = runRunner([
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
'--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid,
'--temp-write-sid', privateTempSid,
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe,
])
expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0)
@@ -267,6 +351,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true)
} finally {
grant.dispose()
rmSync(privateTemp, { recursive: true, force: true })
}
}, 30_000)
@@ -338,4 +423,23 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(result.status).toBe(127)
expect(result.stderr).toContain('windows-acl-run: ')
}, 15_000)
it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => {
const writeSid = workspaceWriteSid(writableDir)
const tempSid = tempWriteSid(isolatedTemp)
const cases = [
['--write-sid', writeSid],
['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid],
['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'],
]
for (const args of cases) {
const result = runRunner([
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
...args,
'--', process.execPath, '-e', 'process.exit(99)',
])
expect(result.status, `args: ${args.join(' ')}\nstderr: ${result.stderr}`).toBe(127)
expect(result.stderr).toContain('windows-acl-run: ')
}
}, 15_000)
})

View File

@@ -9,7 +9,7 @@
import { describe, expect, it } from 'vitest'
import { workspaceWriteSid } from '../src/index.ts'
import { tempWriteSid, workspaceWriteSid } from '../src/index.ts'
describe('workspaceWriteSid', () => {
it('derives a stable orphan-shaped SID per workspace path', () => {
@@ -28,3 +28,16 @@ describe('workspaceWriteSid', () => {
expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo'))
})
})
describe('tempWriteSid', () => {
it('derives a stable domain-separated SID per private temp path', () => {
const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')
expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u)
expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123'))
})
it('derives distinct capabilities for distinct private temp paths', () => {
expect(tempWriteSid('C:\\Temp\\dsh-a')).not.toBe(tempWriteSid('C:\\Temp\\dsh-b'))
})
})