feat(sandbox): Windows ACL write-restriction sandbox (restricted-token runner)
Confine Windows command execution through a WRITE_RESTRICTED token whose restricting SIDs carry an orphan-SID write allowlist, ported from https://github.com/huoyaoyuan/windows-acl-restrict-poc (@ 10e4dfb). Every Win32 call is checked and fails closed - the POC silently ran children with the FULL token when CreateRestrictedToken failed. - @deepseek-ai/dsh-sandbox-windows-acl: koffi primitives verified against the MinGW Windows headers (verify/abi-probe.cpp) plus the confinement runner ([node, runner, --workspace, --temp, --mode, --, argv...]: kill-on-close job, stdio passthrough, exit-code mirroring, windows-acl-run: failure signature, grant revocation). read-only = strict zero grants (NUL device not writable; documented). Windows-only execution: exempted from the Linux coverage lane (windowsOnlyCoverageExclusions). - @deepseek-ai/dsh-sandbox-local: PLATFORM_CHAINS.win32 filled with the windows-acl runner (full enforcement, ACL denial dialect, runner-failure rules). - @deepseek-ai/dsh-pwsh-sandbox: sandbox-consuming pwsh executor (call-for-call mirror of dsh-bash-sandbox) over a new argv-level seam in dsh-pwsh-local; per-file coverage complete via the fake-provider spec. - bundle/base: the Windows platform layer mounts the confined pwsh roster - sandbox/policy/fs-sandbox/permission/approval re-enabled, the POSIX bash stack stays disabled. Co-authored-by: Huo Yaoyuan <huoyaoyuan@hotmail.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* End-to-end probe of the ACL write-restriction sandbox, using the same
|
||||
* probes as the POC verification harness: the confined child must be able to
|
||||
* write into the granted target and temp directories, must be DENIED writing
|
||||
* anywhere else, and (documented boundary) may still READ outside — the
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { AclSandbox } from '../src/index.ts'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
|
||||
function pwshAvailable(): boolean {
|
||||
try {
|
||||
execFileSync('where.exe', ['pwsh'], { stdio: 'ignore' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () => {
|
||||
let scratchRoot!: string
|
||||
let writableDir!: string
|
||||
let isolatedTemp!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let sandbox: AclSandbox
|
||||
|
||||
beforeAll(async () => {
|
||||
scratchRoot = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-acl-sandbox-temp-'))
|
||||
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 })
|
||||
await sandbox.init()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
sandbox.dispose()
|
||||
rmSync(scratchRoot, { recursive: true, force: true })
|
||||
rmSync(isolatedTemp, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('allows writes only in granted directories and denies the escape write', async () => {
|
||||
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'}`,
|
||||
].join('')
|
||||
const child = sandbox.spawn({
|
||||
command: 'pwsh',
|
||||
args: ['/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe],
|
||||
cwd: writableDir,
|
||||
})
|
||||
const result = await child.wait()
|
||||
const output = result.stdout.toString('utf8') + result.stderr.toString('utf8')
|
||||
|
||||
expect(result.exitCode, `child output:\n${output}`).toBe(0)
|
||||
expect(output, `child output:\n${output}`).toContain('TARGET-WRITE: OK')
|
||||
expect(output, `child output:\n${output}`).toContain('TEMP-WRITE: OK')
|
||||
expect(output, `child output:\n${output}`).toContain('ESCAPE-WRITE: DENIED')
|
||||
// Documented boundary: WRITE_RESTRICTED intersects write accesses only,
|
||||
// so reads outside the allowlist still succeed.
|
||||
expect(output, `child output:\n${output}`).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(escapeFile)).toBe(false)
|
||||
expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true)
|
||||
}, 30_000)
|
||||
|
||||
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' })
|
||||
await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u)
|
||||
}, 15_000)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The win32 chain's argv contract, denial dialect, and runner-failure rules,
|
||||
* exercised through the REAL LocalSandboxProvider.confine() with an injected
|
||||
* platform and runner argv prefix. Platform-independent assertions: they run
|
||||
* in every CI lane (Windows included, where sandbox-local's own POSIX-only
|
||||
* suites are excluded) — the end-to-end runner behavior lives in
|
||||
* runner.spec.ts on win32 hosts.
|
||||
*/
|
||||
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
|
||||
const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
||||
|
||||
async function setup(internals: LocalSandboxProvider['internals']) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSandboxProvider, {})
|
||||
const sandbox = ctx.sandbox as LocalSandboxProvider
|
||||
sandbox.internals = internals
|
||||
return sandbox
|
||||
}
|
||||
|
||||
describe('windows-acl win32 chain (LocalSandboxProvider)', () => {
|
||||
it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => {
|
||||
const probeWindowsAcl = vi.fn(() => true)
|
||||
const sandbox = await setup({
|
||||
platform: 'win32',
|
||||
windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'],
|
||||
probeWindowsAcl,
|
||||
})
|
||||
const confined = sandbox.confine(['pwsh', '/Command', 'x'], WW)
|
||||
expect(confined.argv).toEqual([
|
||||
'node', 'windows-acl-runner.js',
|
||||
'--workspace', '/ws',
|
||||
'--temp', tmpdir(),
|
||||
'--mode', 'workspace-write',
|
||||
'--',
|
||||
'pwsh', '/Command', 'x',
|
||||
])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied'])
|
||||
expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }])
|
||||
// A sole candidate is selected unprobed.
|
||||
expect(probeWindowsAcl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('read-only: same runner and contract, read-only mode flag', async () => {
|
||||
const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] })
|
||||
const confined = sandbox.confine(['true'], RO)
|
||||
expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true'])
|
||||
expect(confined.enforcement).toBe('full')
|
||||
expect(confined.runnerFailureRules).toEqual([{ fatalSignatures: ['windows-acl-run: '] }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
const isWin32 = process.platform === 'win32'
|
||||
const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url))
|
||||
|
||||
function pwshAvailable(): boolean {
|
||||
try {
|
||||
spawnSync('where.exe', ['pwsh'], { stdio: 'ignore' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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'}`,
|
||||
].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(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', () => {
|
||||
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'}`,
|
||||
].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(existsSync(join(writableDir, 'readonly-child-wrote.txt'))).toBe(false)
|
||||
}, 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)
|
||||
})
|
||||
Reference in New Issue
Block a user