fix(sandbox): reject overlapping Windows temp roots

This commit is contained in:
Tianyi Cui
2026-08-10 17:54:48 +08:00
parent 1762d2b3d7
commit 9435ca62a6
16 changed files with 167 additions and 16 deletions

View File

@@ -9,7 +9,7 @@
* whose per-test lock file is removed in cleanup.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -207,6 +207,26 @@ describe.skipIf(!isWin32)('ACL editing', () => {
expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false)
})
it('rejects an overlapping private temp directory before applying either capability', async () => {
const workspaceDir = scratch()
const nestedTemp = join(workspaceDir, 'temp')
const writeSid = 'S-1-4-9000-30'
const privateTempSid = 'S-1-4-9000-30-1'
mkdirSync(nestedTemp)
const sandbox = new AclSandbox({
writableDirs: [workspaceDir],
tempDir: nestedTemp,
writeSid,
tempWriteSid: privateTempSid,
mode: 'workspace-write',
})
await expect(sandbox.init()).rejects.toThrow(/private temp directory must be disjoint/u)
const api = await win32()
expect(readDirectAces(api, workspaceDir).some(ace => ace.sid === writeSid)).toBe(false)
expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).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' }))

View File

@@ -0,0 +1,53 @@
/** Canonical path-overlap checks that keep workspace and temp capabilities separate. */
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from '../src/path-boundary.ts'
describe('Windows ACL temp path boundary', () => {
const scratchDirs: string[] = []
afterEach(() => {
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function scratch(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-boundary-'))
scratchDirs.push(dir)
return dir
}
it('rejects a temp root equal to or below the workspace', () => {
const workspace = scratch()
const nested = join(workspace, 'temp')
mkdirSync(nested)
expect(() => assertTempRootOutsideWorkspace(workspace, workspace)).toThrow(/temp root must be outside the workspace/u)
expect(() => assertTempRootOutsideWorkspace(workspace, nested)).toThrow(/temp root must be outside the workspace/u)
})
it('accepts a temp parent above the workspace because a fresh child is a sibling', () => {
const tempRoot = scratch()
const workspace = join(tempRoot, 'workspace')
mkdirSync(workspace)
expect(() => assertTempRootOutsideWorkspace(workspace, tempRoot)).not.toThrow()
})
it('requires an actual private temp directory to be disjoint in either direction', () => {
const root = scratch()
const workspace = join(root, 'workspace')
const nestedTemp = join(workspace, 'temp')
const siblingTemp = join(root, 'sibling-temp')
mkdirSync(workspace)
mkdirSync(nestedTemp)
mkdirSync(siblingTemp)
expect(() => assertPrivateTempDisjoint([workspace], nestedTemp)).toThrow(/must be disjoint/u)
expect(() => assertPrivateTempDisjoint([nestedTemp], workspace)).toThrow(/must be disjoint/u)
expect(() => assertPrivateTempDisjoint([workspace], siblingTemp)).not.toThrow()
})
})

View File

@@ -277,6 +277,22 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
expect(existsSync(tempB)).toBe(false)
}, 30_000)
it('agentless workspace-write rejects a temp root inside the workspace before spawning', () => {
const overlapWorkspace = join(scratchRoot, 'overlap-workspace')
const nestedTempRoot = join(overlapWorkspace, 'temp')
const marker = join(overlapWorkspace, 'command-ran.txt')
mkdirSync(overlapWorkspace)
mkdirSync(nestedTempRoot)
const result = runRunner([
'--workspace', overlapWorkspace, '--temp', nestedTempRoot, '--mode', 'workspace-write',
'--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'ran')", marker,
])
expect(result.status, `stderr: ${result.stderr}`).toBe(127)
expect(result.stderr).toContain('windows-acl-run: Windows ACL temp root must be outside the workspace')
expect(existsSync(marker)).toBe(false)
}, 15_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),