fix(sandbox): reject overlapping Windows temp roots
This commit is contained in:
@@ -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' }))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user