Files
ycode/scripts/sandbox-policy-regression.ts
T
xiaohan.yuan b2d37472f0 fix(sandbox): support real development workspaces
- raise finite development defaults/ceilings and queue capacities
- migrate untouched v1 default policy
- exclude generated output and shipped runtime paths from snapshots
- report snapshot limit errors with the offending path
2026-10-18 18:08:20 +08:00

180 lines
16 KiB
TypeScript

import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { readSandboxPolicy, updateSandboxPolicy, effectiveSandboxPolicy, validatePolicyOverride } from '../src/main/sandbox/policy-store'
import { authorizeSandboxWrite } from '../src/main/sandbox/authorizations'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
import { executeTool } from '../src/main/agent/tools'
import { runSandboxSelfTest } from '../src/main/sandbox/self-test'
import { digest, restoreTransaction } from '../src/main/sandbox/transactions'
import { sandboxOperation } from '../src/main/sandbox/maintenance'
import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker'
import type { ChatEvent } from '../src/shared/types'
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-policy-'))
const workspace = join(fixture, 'workspace'), other = join(fixture, 'other'), stateRoot = join(fixture, 'state')
await fs.mkdir(workspace); await fs.mkdir(other)
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
await fs.mkdir(join(workspace, 'private')); await fs.writeFile(join(workspace, 'private/file.txt'), 'private-dummy')
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
let document = await readSandboxPolicy()
const context = { workspaceRoot: workspace, sessionId: 'policy-session', runId: 'policy-run', toolCallId: 'policy-tool', approve: async () => true }
async function update(rules: Parameters<typeof updateSandboxPolicy>[0]['rules'], root?: string): Promise<void> {
document = await updateSandboxPolicy({ expectedRevision: document.revision, rules, ...(root ? { workspaceRoot: root } : {}) })
}
try {
check('missing policy loads safe deterministic defaults without writing state', document.defaults.commandsEnabled && document.defaults.writeback === 'ask' && document.updatedAt === 0 && !await fs.stat(join(stateRoot, 'policy.json')).catch(() => null))
for (const value of [{ network: 'allow' }, { writeback: 'automatic' }, { limits: { files: -1 } }, { limits: { timeoutMs: Infinity } }, { deniedPaths: ['../escape'] }, { deniedPaths: ['C:/host'] }, { deniedPaths: ['a\\..\\escape'] }]) assert.throws(() => validatePolicyOverride(value))
check('unsupported permissions and malformed policies cannot be enabled', true)
const initialRevision = document.revision
await update({ deniedPaths: ['private'], limits: { files: 500 } })
check('policy is atomically persisted with a new revision', document.revision !== initialRevision && (await readSandboxPolicy()).revision === document.revision)
await assert.rejects(updateSandboxPolicy({ expectedRevision: initialRevision, rules: { commandsEnabled: false } }), /版本冲突/)
check('stale policy compare-and-swap is rejected', (await readSandboxPolicy()).defaults.commandsEnabled)
await update({ commandsEnabled: false, deniedPaths: ['workspace-only'], limits: { files: 10 } }, workspace)
check('workspace policy applies only to its canonical workspace', !(await effectiveSandboxPolicy(workspace)).commandsEnabled && (await effectiveSandboxPolicy(other)).commandsEnabled)
await update({ commandsEnabled: true, deniedPaths: [] }, workspace)
check('workspace overrides cannot remove global path restrictions', (await effectiveSandboxPolicy(workspace)).deniedPaths.includes('private'))
check('workspace limits cannot widen global limits', (await effectiveSandboxPolicy(workspace)).limits.files === 10)
let tool = await executeTool('read_file', { path: 'private/file.txt' }, context)
check('host file reads enforce the effective denied-path policy', !tool.ok && !tool.content.includes('private-dummy'))
tool = await executeTool('write_file', { path: 'private/file.txt', content: 'unsafe' }, context)
check('host writes cannot bypass denied paths', !tool.ok && await fs.readFile(join(workspace, 'private/file.txt'), 'utf8') === 'private-dummy')
await update({ limits: { files: 500 } }, workspace)
const targets = digest(Buffer.from('target-and-content'))
let policy = await effectiveSandboxPolicy(workspace)
let ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
assert.ok(ticket)
await assert.rejects(ticket.consume(digest(Buffer.from('another-target'))), /目标或内容/)
await ticket.consume(targets)
await assert.rejects(ticket.consume(targets), /已使用/)
check('authorization binds the exact operation and is single use', true)
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
context.toolCallId = 'another-call'
await assert.rejects(ticket!.consume(targets), /归属/)
context.toolCallId = 'policy-tool'
check('authorization rejects changed call ownership', true)
const substitutedOwner = { ...context, approve: async () => { substitutedOwner.toolCallId = 'substituted-during-approval'; return true } }
await assert.rejects(authorizeSandboxWrite(policy, 'approve', substitutedOwner, targets), /归属/)
check('approval captures ownership before waiting for the user', true)
const substitutedWorkspace = { ...context, approve: async () => { substitutedWorkspace.workspaceRoot = other; return true } }
await assert.rejects(authorizeSandboxWrite(policy, 'approve', substitutedWorkspace, targets), /归属/)
check('approval cannot migrate to a different workspace while pending', true)
const abort = new AbortController()
await assert.rejects(authorizeSandboxWrite(policy, 'approve', { ...context, signal: abort.signal, approve: async () => { abort.abort(); return true } }, targets), /取消/)
check('cancellation during approval never grants a ticket', true)
const now = Date.now
try {
await assert.rejects(authorizeSandboxWrite(policy, 'approve', { ...context, approve: async () => { Date.now = () => now() + 11 * 60_000; return true } }, targets), /过期/)
} finally { Date.now = now }
check('an approval arriving after its deadline is rejected', true)
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
await update({ maxChanges: 3 })
await assert.rejects(ticket!.consume(targets), /策略已变化/)
check('policy updates revoke existing authorization', true)
policy = await effectiveSandboxPolicy(workspace)
ticket = await authorizeSandboxWrite(policy, 'approve', context, targets)
const unchangedRevisionBytes = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
const editedWithoutRevision = JSON.parse(unchangedRevisionBytes); editedWithoutRevision.defaults.commandsEnabled = false
await fs.writeFile(join(stateRoot, 'policy.json'), JSON.stringify(editedWithoutRevision))
await assert.rejects(ticket!.consume(targets), /策略已变化/)
await fs.writeFile(join(stateRoot, 'policy.json'), unchangedRevisionBytes)
await assert.rejects(ticket!.consume(targets), /过期|归属/)
check('policy content changes without a new revision still revoke approval permanently', true)
let approvals = 0
tool = await executeTool('write_file', { path: 'sample.txt', content: 'after' }, { ...context, approve: async () => { approvals++; await update({ maxChanges: 4 }); return true } })
check('a policy update while the user approves a host write prevents mutation', !tool.ok && approvals === 1 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
const args = { path: 'sample.txt', content: 'approved-content' }
tool = await executeTool('write_file', args, { ...context, approve: async () => { args.content = 'substituted'; return true } })
check('approved host content cannot be substituted by mutating the request', tool.ok && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'approved-content')
const transactionId = (tool.data as { transactionId: string }).transactionId
const transaction = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId, 'manifest.json'), 'utf8'))
check('transaction persists policy, authorization and session identity', transaction.identity.sessionId === context.sessionId && transaction.identity.policyRevision === document.revision && !!transaction.identity.authorizationId)
await restoreTransaction(transactionId, workspace)
await update({ writeback: 'deny' })
approvals = 0
tool = await executeTool('write_file', { path: 'sample.txt', content: 'denied' }, { ...context, approve: async () => { approvals++; return true } })
check('deny-writeback policy applies before prompting', !tool.ok && approvals === 0 && await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'before')
await update({ writeback: 'ask' })
if (process.platform !== 'win32') throw new Error('This suite includes real Windows native checks; unsupported hosts cannot report a pass')
let result = await executeSandboxCommand('type private\\file.txt', context)
check('command snapshots enforce the same denied paths as host tools', !result.ok && !result.data.stdout.includes('private-dummy'))
await assert.rejects(executeSandboxCommand('mkdir private & echo denied>private\\new.txt', context), /受保护路径|策略/)
check('command-generated denied paths cannot reach the host', !await fs.stat(join(workspace, 'private/new.txt')).catch(() => null))
await update({ commandsEnabled: false })
await assert.rejects(executeSandboxCommand('echo unsafe>disabled.txt', context), /禁用/)
check('command-disable policy is enforced by the native route', !await fs.stat(join(workspace, 'disabled.txt')).catch(() => null))
await update({ commandsEnabled: true, limits: { fileBytes: 4 } })
await assert.rejects(executeSandboxCommand('echo unsafe>size.txt', context), /上限/)
check('configured snapshot byte limits are enforced before execution', !await fs.stat(join(workspace, 'size.txt')).catch(() => null))
await update({ limits: { fileBytes: 256 * 1024 * 1024 } })
result = await executeSandboxCommand('echo approved>sample.txt', context)
check('native writeback records policy and authorization identities', result.ok && result.data.policyRevision === document.revision && !!result.data.authorizationId)
const operation = await sandboxOperation(result.data.runId)
check('host commit outcome can be queried independently of the chat response', operation.writeback === 'committed' && operation.transactionId === result.data.transactionId && operation.identity.sessionId === context.sessionId)
await assert.rejects(sandboxOperation('../outside'), /标识无效/)
check('operation queries reject path traversal identities', true)
await restoreTransaction(result.data.transactionId!, workspace)
const selfTest = await runSandboxSelfTest()
check('self-test persists a binary and policy-bound native observation', selfTest.passed && (await sandboxStatus()).selfTest?.runId === selfTest.runId)
const beforeManualEdit = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
const modified = JSON.parse(beforeManualEdit); modified.defaults.maxChanges = 9
await fs.writeFile(join(stateRoot, 'policy.json'), JSON.stringify(modified))
check('same-revision manual policy edits invalidate a self-test observation', !(await sandboxStatus()).selfTest)
await fs.writeFile(join(stateRoot, 'policy.json'), beforeManualEdit)
await update({ maxChanges: 2 })
check('policy changes invalidate an old self-test observation', !(await sandboxStatus()).selfTest)
policy = await effectiveSandboxPolicy(workspace)
const liveCommand = executeSandboxCommand('powershell.exe -NoProfile -NonInteractive -Command "Start-Sleep -Seconds 10"', context)
const cancelByPolicy = setTimeout(() => { void update({ commandsEnabled: false }).catch(() => undefined) }, 900)
await assert.rejects(liveCommand, /策略已变化/)
clearTimeout(cancelByPolicy)
check('an external policy revision stops an active native process', true)
await update({ commandsEnabled: true })
const approvalController = new AbortController(), events: ChatEvent[] = []
let pendingReady!: (id: string) => void
const ready = new Promise<string>(done => { pendingReady = done })
const emit = (event: ChatEvent): void => { events.push(event); if (event.type === 'interaction' && event.interaction.status === 'pending') pendingReady(event.interaction.id) }
const broker = createInteractionBroker(context.runId, approvalController.signal, emit)
const pendingWrite = executeTool('write_file', { path: 'revoked.txt', content: 'must-not-write' }, { ...context, signal: approvalController.signal,
approve: async (title, cancellation) => (await broker.approval(context.toolCallId, title, [{ optionId: 'allow-once', name: 'allow', kind: 'allow_once' }], undefined, cancellation)).optionId === 'allow-once' })
const interactionId = await ready
await update({ maxChanges: 3 })
const revokedWrite = await pendingWrite
check('policy revocation cancels the actual pending conversation interaction', !revokedWrite.ok && events.some(event => event.type === 'interaction' && event.interaction.id === interactionId && event.interaction.status === 'cancelled'))
check('a revoked interaction cannot accept a later approval response', !respondToInteraction(context.runId, interactionId, { kind: 'approval', optionId: 'allow-once' }, emit) && !await fs.stat(join(workspace, 'revoked.txt')).catch(() => null))
let releaseApproval!: () => void, approvalStarted!: () => void
const waitApproval = new Promise<boolean>(done => { releaseApproval = () => done(true) }), approvalIsPending = new Promise<void>(done => { approvalStarted = done })
const runCount = (await fs.readdir(join(stateRoot, 'runs'))).length
const first = executeSandboxCommand('echo serialized-first>serialized.txt', { ...context, approve: async () => { approvalStarted(); return waitApproval } })
await approvalIsPending
const second = executeSandboxCommand('type serialized.txt', context)
const queuedAbort = new AbortController()
const third = executeSandboxCommand('echo should-not-run>queued-cancelled.txt', { ...context, signal: queuedAbort.signal })
const thirdRejected = assert.rejects(third, /取消/)
queuedAbort.abort(); await thirdRejected
await new Promise(done => setTimeout(done, 50))
check('same-workspace commands and cancelled queued requests do not start stale snapshots', (await fs.readdir(join(stateRoot, 'runs'))).length === runCount + 1)
releaseApproval()
const firstResult = await first, secondResult = await second
check('a queued command observes the previous approved host commit', firstResult.ok && secondResult.ok && secondResult.data.stdout.includes('serialized-first') && !await fs.stat(join(workspace, 'queued-cancelled.txt')).catch(() => null))
await restoreTransaction(firstResult.data.transactionId!, workspace)
const original = await fs.readFile(join(stateRoot, 'policy.json'), 'utf8')
await fs.writeFile(join(stateRoot, 'policy.json'), '{malformed')
tool = await executeTool('execute_command', { command: 'echo unsafe>corrupt.txt' }, context)
check('corrupt policy fails closed without host execution', !tool.ok && !await fs.stat(join(workspace, 'corrupt.txt')).catch(() => null))
await fs.writeFile(join(stateRoot, 'policy.json'), original)
await fs.writeFile(join(stateRoot, 'policy.lock'), 'existing-foreign-maintenance-lock')
await assert.rejects(updateSandboxPolicy({ expectedRevision: document.revision, rules: {} }), /其他维护进程/)
check('policy updates respect an existing cross-process maintenance lock', await fs.readFile(join(stateRoot, 'policy.lock'), 'utf8') === 'existing-foreign-maintenance-lock')
await fs.unlink(join(stateRoot, 'policy.lock'))
console.log(`SANDBOX_POLICY_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ checks, fixture, limitations: ['Trusted-process authorization contracts and real Windows command integration; child IPC authentication and original WorkBuddy parity remain separate.'] }, null, 2) + '\n')
console.log(`SANDBOX_POLICY_REPORT ${join(fixture, 'report.json')}`)
}