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[0]['rules'], root?: string): Promise { 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(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(done => { releaseApproval = () => done(true) }), approvalIsPending = new Promise(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')}`) }