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

@@ -29,21 +29,19 @@ function pwshAvailable(): boolean {
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
let scratchRoot!: string
let writableDir!: string
let isolatedTemp!: string
let outsideTempDir!: string
let secretFile!: string
let escapeFile!: string
let executor!: SandboxPwshExecutor
beforeAll(async () => {
// The escape probe must live OUTSIDE every legitimately granted tree: the
// provider's workspace-write grants the workspace plus the REAL temp dir
// (the 'backend-defined temp area', same as Landlock granting /tmp), so a
// scratch dir under temp would inherit the grant and the probe would be a
// false pass. A mkdtemp under the profile is removed by afterAll.
// The workspace escape sits under the profile. A separate directory under
// the ambient temp root proves that the root itself is not granted: the
// runner creates its own private child and rewrites TMP/TEMP to it.
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
writableDir = join(scratchRoot, 'writable')
mkdirSync(writableDir)
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-'))
outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-temp-'))
secretFile = join(scratchRoot, 'secret.txt')
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
escapeFile = join(scratchRoot, 'escaped.txt')
@@ -58,7 +56,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
afterAll(() => {
rmSync(scratchRoot, { recursive: true, force: true })
rmSync(isolatedTemp, { recursive: true, force: true })
rmSync(outsideTempDir, { recursive: true, force: true })
})
it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => {
@@ -66,7 +64,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
`try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
`try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
`try{Set-Content -Path '${outsideTempDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
].join('')
@@ -89,23 +87,31 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
}, 60_000)
it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => {
it('workspace-write: workspace and private temp writable, ambient temp and escape denied', async () => {
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir }
const probe = [
"$ErrorActionPreference='SilentlyContinue';",
`try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
`try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
"try{Set-Content -Path (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};",
`try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-TEMP-WRITE: DENIED'};`,
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
"'TEMP-PATH: ' + $env:TEMP",
].join('')
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
expect(result.stdout.text).toContain('TARGET-WRITE: OK')
expect(result.stdout.text).toContain('TEMP-WRITE: OK')
expect(result.stdout.text).toContain('AMBIENT-TEMP-WRITE: DENIED')
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
expect(result.stdout.text).toContain('SECRET-READ: OK')
expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true)
expect(existsSync(join(outsideTempDir, 'ww-write.txt'))).toBe(false)
expect(existsSync(escapeFile)).toBe(false)
const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim()
expect(privateTemp).toBeDefined()
expect(privateTemp?.startsWith(tmpdir())).toBe(true)
expect(existsSync(privateTemp ?? '')).toBe(false)
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' })
}, 60_000)
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8
README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90
README.md: 4126e718c569f17fb8be465351b2576970e93c63
README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7

View File

@@ -120,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
- **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction.

View File

@@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。
## Known Limitations and Deferred Work
- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write受限令牌使 pwsh 进入 ConstrainedLanguage 模式`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::``[math]::`、COM 对象与反射都会以“only core types”错误失败且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
- **Windows 沙箱下的语言模式与 named-pipe 捕获** — [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::``[math]::`、COM 对象与反射都会以“only core types”错误失败且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`PTY 后端目前仅限 Linux/macOSWindows ConPTY 持久 shell 属于路线图工作。
- **PowerShell 方言约定** — 模型必须写 PowerShell原生路径、`$env:` 变量),而不是 bash没有方言翻译。
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。

View File

@@ -114,15 +114,15 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
+ background
if (escalationModes.length === 0) return base
// The CLM and named-pipe contracts below are Windows-restricted-token
// The language-mode and named-pipe contracts below are Windows-restricted-token
// behavior, but the gate is 'any confining executor is mounted'
// (escalationModes non-empty). The conflation is safe today because every
// shipped composition pairing tool-pwsh with a confining executor is
// win32-only; a future POSIX pwsh-sandbox composition must gate both
// sentences on the platform instead (tracked in the pwsh-tool-and-executor
// Agent Note).
return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and '
+ 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
return base + ' Under the Windows sandbox, read-only pwsh runs in PowerShell ConstrainedLanguage mode, while '
+ 'workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
+ '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail '
+ 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. '
+ 'In the same modes, programs cannot open named pipes, so a command that captures another '

View File

@@ -559,6 +559,7 @@ describe('sandbox escalation through ctx.approval', () => {
expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
expect(schema.description).toContain('approval prompt')
expect(schema.description).toContain('ConstrainedLanguage')
expect(schema.description).toContain('workspace-write stays in FullLanguage')
expect(schema.description).toContain('named pipes')
expect(schema.description).toContain('fails with EPERM')