177 lines
9.2 KiB
TypeScript
177 lines
9.2 KiB
TypeScript
/**
|
|
* End-to-end runner tests: spawn the REAL runner entry through tsx (exactly
|
|
* the argv shape dsh-sandbox-local's confine() builds), with piped stdio
|
|
* inherited through the runner into the confined child — the same chain a
|
|
* production confined execution walks.
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process'
|
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
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'
|
|
|
|
const isWin32 = process.platform === 'win32'
|
|
const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
|
|
|
|
// Functional probe, not where.exe: spawnSync never throws on a missing
|
|
// binary (status null) and where.exe exits 1 without pwsh — only an actual
|
|
// pwsh invocation's exit status is truth.
|
|
function pwshAvailable(): boolean {
|
|
return spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
|
|
}
|
|
|
|
function runRunner(args: string[], timeoutMs = 30_000) {
|
|
return spawnSync(process.execPath, ['--import', 'tsx/esm', runnerEntry, ...args], {
|
|
timeout: timeoutMs,
|
|
encoding: 'utf8',
|
|
})
|
|
}
|
|
|
|
describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => {
|
|
let scratchRoot!: string
|
|
let writableDir!: string
|
|
let isolatedTemp!: string
|
|
let secretFile!: string
|
|
let escapeFile!: string
|
|
|
|
beforeAll(() => {
|
|
scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-'))
|
|
writableDir = join(scratchRoot, 'writable')
|
|
mkdirSync(writableDir)
|
|
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-runner-temp-'))
|
|
secretFile = join(scratchRoot, 'secret.txt')
|
|
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
|
escapeFile = join(scratchRoot, 'escaped.txt')
|
|
})
|
|
|
|
afterAll(() => {
|
|
rmSync(scratchRoot, { recursive: true, force: true })
|
|
rmSync(isolatedTemp, { recursive: true, force: true })
|
|
})
|
|
|
|
it('workspace-write: the confined child writes granted directories only', () => {
|
|
const probe = [
|
|
"$ErrorActionPreference='SilentlyContinue';",
|
|
`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 '${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'};`,
|
|
// List J carries Authenticated Users: the CIM path (WMI namespace
|
|
// security check) stays alive under workspace-write.
|
|
"try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}",
|
|
].join('')
|
|
const result = runRunner([
|
|
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
|
|
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
|
])
|
|
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
|
expect(result.stdout).toContain('TARGET-WRITE: OK')
|
|
expect(result.stdout).toContain('TEMP-WRITE: OK')
|
|
expect(result.stdout).toContain('ESCAPE-WRITE: DENIED')
|
|
expect(result.stdout).toContain('SECRET-READ: OK')
|
|
expect(result.stdout).toContain('CIM: OK')
|
|
expect(existsSync(escapeFile)).toBe(false)
|
|
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
|
|
}, 30_000)
|
|
|
|
it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable (list I)', () => {
|
|
const probe = [
|
|
"$ErrorActionPreference='SilentlyContinue';",
|
|
'\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;',
|
|
`try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
|
`try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
|
// The NUL device is a securable object: strict zero grants deny it too.
|
|
'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};',
|
|
// PowerShell's $null redirection discards without opening NUL — must keep working.
|
|
'echo hi > $null;\'DOLLAR-NULL: OK\';',
|
|
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
|
|
// List I drops Authenticated Users: the WMI namespace security check
|
|
// fails (0x80041003) — the documented read-only CIM boundary, the
|
|
// price of the zero ambient-write surface.
|
|
"try{Get-CimInstance Win32_OperatingSystem -ErrorAction Stop | Out-Null;'CIM: OK'}catch{'CIM: DENIED'}",
|
|
].join('')
|
|
const result = runRunner([
|
|
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only',
|
|
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
|
])
|
|
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
|
expect(result.stdout).toContain('TARGET-WRITE: DENIED')
|
|
expect(result.stdout).toContain('TEMP-WRITE: DENIED')
|
|
expect(result.stdout).toContain('NUL-WRITE: DENIED')
|
|
expect(result.stdout).toContain('DOLLAR-NULL: OK')
|
|
expect(result.stdout).toContain('SECRET-READ: OK')
|
|
expect(result.stdout).toContain('CIM: DENIED')
|
|
expect(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false)
|
|
}, 30_000)
|
|
|
|
it('workspace-write: Remove-Item and Rename-Item succeed in the granted workspace (DELETE + FILE_DELETE_CHILD)', () => {
|
|
// Deleting a file and renaming a directory both hit the second access
|
|
// check on the workspace itself: the grant must carry DELETE (on the
|
|
// object) and FILE_DELETE_CHILD (on its parent).
|
|
const victimFile = join(writableDir, 'delete-me.txt')
|
|
writeFileSync(victimFile, 'remove me')
|
|
const victimDir = join(writableDir, 'rename-me')
|
|
mkdirSync(victimDir)
|
|
const renamedDir = join(writableDir, 'renamed-by-child')
|
|
const probe = [
|
|
"$ErrorActionPreference='SilentlyContinue';",
|
|
`try{Remove-Item -LiteralPath '${victimFile}' -ErrorAction Stop;'DELETE-FILE: OK'}catch{'DELETE-FILE: DENIED'};`,
|
|
`try{Rename-Item -LiteralPath '${victimDir}' -NewName 'renamed-by-child' -ErrorAction Stop;'RENAME-DIR: OK'}catch{'RENAME-DIR: DENIED'}`,
|
|
].join('')
|
|
const result = runRunner([
|
|
'--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write',
|
|
'--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe,
|
|
])
|
|
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
|
expect(result.stdout).toContain('DELETE-FILE: OK')
|
|
expect(result.stdout).toContain('RENAME-DIR: OK')
|
|
expect(existsSync(victimFile)).toBe(false)
|
|
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'
|
|
const privateTemp = join(isolatedTemp, 'private-subdir')
|
|
mkdirSync(privateTemp)
|
|
const grant = AclWriteGrant.create(writeSid)
|
|
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 '${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,
|
|
'--', '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
|
|
// 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(privateTemp, 'server-granted.txt'))).toBe(true)
|
|
} finally {
|
|
grant.dispose()
|
|
rmSync(privateTemp, { recursive: true, force: true })
|
|
}
|
|
}, 30_000)
|
|
|
|
it('runner-side failure: signature on stderr and exit 127, the command never runs', () => {
|
|
const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write'])
|
|
expect(result.status).toBe(127)
|
|
expect(result.stderr).toContain('windows-acl-run: ')
|
|
}, 15_000)
|
|
})
|