164 lines
16 KiB
TypeScript
164 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 { NativeWorkspaceWriter } from '../src/main/sandbox/filesystem-writer'
|
|
import { digest } from '../src/main/sandbox/transactions'
|
|
import { commitChanges, restoreTransaction } from '../src/main/sandbox/transactions'
|
|
import { executeSandboxCommand } from '../src/main/sandbox/service'
|
|
import { executeTool } from '../src/main/agent/tools'
|
|
import { execFileSync, type ChildProcess } from 'node:child_process'
|
|
import { randomUUID } from 'node:crypto'
|
|
|
|
if (process.platform !== 'win32') throw new Error('Native write integration requires Windows')
|
|
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
|
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-write-'))
|
|
const workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
|
await fs.mkdir(workspace)
|
|
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}`) }
|
|
try {
|
|
await fs.writeFile(join(workspace, 'sample.txt'), 'before')
|
|
const writer = await NativeWorkspaceWriter.create(workspace)
|
|
try {
|
|
const before = await writer.inspect('sample.txt', true)
|
|
check('native transaction reads exact bytes under a pinned root', before.bytes?.toString() === 'before')
|
|
await writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('after'))
|
|
check('replacement succeeds while original target remains pinned', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
|
await assert.rejects(writer.replace('sample.txt', digest(Buffer.from('before')), Buffer.from('wrong')), /changed/)
|
|
check('stale expected content cannot overwrite the target', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
|
} finally { await writer.close() }
|
|
await fs.mkdir(join(workspace, 'nested'))
|
|
await fs.writeFile(join(workspace, 'nested/data.bin'), Buffer.from([0, 255, 1, 127]))
|
|
const outside = join(fixture, 'outside'); await fs.mkdir(outside)
|
|
await fs.writeFile(join(outside, 'data.bin'), 'OUTSIDE_UNCHANGED')
|
|
const pinned = await NativeWorkspaceWriter.create(workspace)
|
|
try {
|
|
await pinned.inspect('nested/data.bin', true)
|
|
const moved = await fs.rename(join(workspace, 'nested'), join(workspace, 'moved')).then(() => true, () => false)
|
|
check('pinned ancestor cannot be renamed into a junction attack', !moved)
|
|
const rootMoved = await fs.rename(workspace, join(fixture, 'replaced-root')).then(() => true, () => false)
|
|
check('workspace root remains pinned for the whole transaction session', !rootMoved)
|
|
await pinned.replace('nested/data.bin', digest(Buffer.from([0, 255, 1, 127])), Buffer.alloc(0))
|
|
check('nested binary-to-empty replacement preserves byte semantics', (await fs.stat(join(workspace, 'nested/data.bin'))).size === 0)
|
|
await pinned.replace('created.bin', null, Buffer.from([0, 128, 255]))
|
|
await assert.rejects(pinned.replace('created.bin', null, Buffer.from('overwrite')), /changed/)
|
|
check('creation refuses to overwrite a target that already exists', (await fs.readFile(join(workspace, 'created.bin'))).equals(Buffer.from([0, 128, 255])))
|
|
for (const path of ['../outside/data.bin', '.env', 'stream.txt:ads', '.ycode-sandbox-owned.tmp']) await assert.rejects(pinned.replace(path, null, Buffer.from('blocked')))
|
|
check('outside, sensitive, ADS and reserved transaction paths are denied', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
|
await fs.symlink(outside, join(workspace, 'junction'), 'junction')
|
|
await assert.rejects(pinned.replace('junction/data.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')))
|
|
check('native relative opens reject a junction target', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
|
await fs.unlink(join(workspace, 'junction'))
|
|
await fs.link(join(outside, 'data.bin'), join(workspace, 'hardlink.bin'))
|
|
await assert.rejects(pinned.replace('hardlink.bin', digest(Buffer.from('OUTSIDE_UNCHANGED')), Buffer.from('blocked')), /hard link/)
|
|
check('native replacement rejects hard-linked files', await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
|
await fs.unlink(join(workspace, 'hardlink.bin'))
|
|
const busy = await fs.open(join(workspace, 'sample.txt'), 'r+')
|
|
try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /win32=32/) }
|
|
finally { await busy.close() }
|
|
check('existing writable handles fail closed without relaxed sharing', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
|
await fs.chmod(join(workspace, 'sample.txt'), 0o444)
|
|
try { await assert.rejects(pinned.replace('sample.txt', digest(Buffer.from('after')), Buffer.from('blocked')), /read-only/) }
|
|
finally { await fs.chmod(join(workspace, 'sample.txt'), 0o666) }
|
|
check('read-only targets are rejected before handoff', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'after')
|
|
await pinned.replace('created.bin', digest(Buffer.from([0, 128, 255])), null)
|
|
check('native deletion removes only the verified file object', !await fs.stat(join(workspace, 'created.bin')).catch(() => null))
|
|
} finally { await pinned.close() }
|
|
await fs.rename(join(workspace, 'nested'), join(workspace, 'released'))
|
|
check('session close releases ancestor pins', (await fs.stat(join(workspace, 'released'))).isDirectory())
|
|
const context = { workspaceRoot: workspace, sessionId: 'write-session', runId: 'write-run', toolCallId: 'write-call', approve: async () => true }
|
|
await fs.copyFile(resolve('resources/sandbox/win32-x64/ycode-sandbox.exe'), join(workspace, 'runner-copy.exe'))
|
|
const hostOnly = await executeSandboxCommand('runner-copy.exe --workspace-write "%CD%" -', context)
|
|
check('sandbox descendants cannot invoke the host transaction entry point', !hostOnly.ok && /host-only/.test(hostOnly.data.stderr))
|
|
await fs.unlink(join(workspace, 'runner-copy.exe'))
|
|
const tool = await executeTool('write_file', { path: 'implicit/a/b/file.txt', content: 'implicit-content' }, context)
|
|
const transactionId = (tool.data as { transactionId?: string })?.transactionId
|
|
check('default file tool commits through the native transaction backend', tool.ok && !!transactionId && await fs.readFile(join(workspace, 'implicit/a/b/file.txt'), 'utf8') === 'implicit-content')
|
|
const journal = JSON.parse(await fs.readFile(join(stateRoot, 'transactions', transactionId!, 'manifest.json'), 'utf8'))
|
|
check('journal binds root identity and records implicit parents and mutation intent', journal.version === 3 && journal.workspaceIdentity && journal.directories.length === 3 && journal.mutations.length === 1)
|
|
await restoreTransaction(transactionId!, workspace)
|
|
check('recovery removes implicitly created parents as well as file bytes', !await fs.stat(join(workspace, 'implicit')).catch(() => null))
|
|
let attempts = 0
|
|
const guardedId = await commitChanges(workspace, [{ path: 'released/data.bin', beforeHash: digest(Buffer.alloc(0)), after: Buffer.from('guarded') }], context, undefined, {
|
|
beforeApply: async () => {
|
|
for (let i = 0; i < 20; i++) if (!await fs.rename(join(workspace, 'released'), join(workspace, 'attack-moved')).then(() => true, () => false)) attempts++
|
|
}, check: async () => undefined
|
|
})
|
|
check('real commit rejects repeated ancestor replacement attempts after preflight', attempts === 20 && await fs.readFile(join(workspace, 'released/data.bin'), 'utf8') === 'guarded' && await fs.readFile(join(outside, 'data.bin'), 'utf8') === 'OUTSIDE_UNCHANGED')
|
|
await restoreTransaction(guardedId, workspace)
|
|
await fs.mkdir(join(workspace, 'delete-tree')); await fs.writeFile(join(workspace, 'delete-tree/original.txt'), 'original')
|
|
await assert.rejects(commitChanges(workspace, [{ path: 'delete-tree/original.txt', beforeHash: digest(Buffer.from('original')), after: null }], context, undefined,
|
|
{ beforeApply: async () => { await fs.writeFile(join(workspace, 'delete-tree/external.txt'), 'external') }, check: async () => undefined }, [{ path: 'delete-tree', before: true, after: false }]))
|
|
check('late directory additions stop deletion and compensate earlier file removals', await fs.readFile(join(workspace, 'delete-tree/original.txt'), 'utf8') === 'original' && await fs.readFile(join(workspace, 'delete-tree/external.txt'), 'utf8') === 'external')
|
|
await assert.rejects(commitChanges(workspace, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('after')), after: Buffer.from('approved') }], context, undefined,
|
|
{ beforeApply: async () => { await fs.writeFile(join(workspace, 'sample.txt'), 'external-edit') }, check: async () => undefined }))
|
|
check('post-preflight external file edits survive a rejected commit', await fs.readFile(join(workspace, 'sample.txt'), 'utf8') === 'external-edit')
|
|
for (const route of ['command', 'file-tool']) {
|
|
const targetRoot = join(fixture, `approval-${route}`), movedRoot = `${targetRoot}-old`
|
|
await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval')
|
|
const approve = async (): Promise<boolean> => { await fs.rename(targetRoot, movedRoot); await fs.mkdir(targetRoot); await fs.writeFile(join(targetRoot, 'sample.txt'), 'before-approval'); return true }
|
|
if (route === 'command') await assert.rejects(executeSandboxCommand('echo approved>sample.txt', { ...context, workspaceRoot: targetRoot, approve }), /identity changed|被替换/)
|
|
else assert.equal((await executeTool('write_file', { path: 'sample.txt', content: 'approved' }, { ...context, workspaceRoot: targetRoot, approve })).ok, false)
|
|
check(`${route} approval cannot authorize a replacement root with identical contents`, await fs.readFile(join(targetRoot, 'sample.txt'), 'utf8') === 'before-approval' && await fs.readFile(join(movedRoot, 'sample.txt'), 'utf8') === 'before-approval')
|
|
}
|
|
const rootRecovery = join(fixture, 'root-recovery'); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'before')
|
|
const rootTransaction = await commitChanges(rootRecovery, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('before')), after: Buffer.from('after') }])
|
|
await fs.rename(rootRecovery, `${rootRecovery}-old`); await fs.mkdir(rootRecovery); await fs.writeFile(join(rootRecovery, 'sample.txt'), 'after')
|
|
await assert.rejects(restoreTransaction(rootTransaction, rootRecovery), /identity changed/)
|
|
check('recovery refuses a same-path replacement root with matching file hashes', await fs.readFile(join(rootRecovery, 'sample.txt'), 'utf8') === 'after')
|
|
await fs.writeFile(join(workspace, 'first.txt'), 'first-before'); await fs.writeFile(join(workspace, 'second.txt'), 'second-before')
|
|
let phase = 0
|
|
const killed = await commitChanges(workspace, [
|
|
{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('first-after') },
|
|
{ path: 'second.txt', beforeHash: digest(Buffer.from('second-before')), after: Buffer.from('second-after') }
|
|
], context, undefined, { beforeApply: async () => undefined, check: async () => {
|
|
if (++phase !== 2) return
|
|
const query = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${process.pid}' | Where-Object { $_.Name -eq 'ycode-sandbox.exe' } | Select-Object -ExpandProperty ProcessId`
|
|
const pids = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', query], { encoding: 'utf8', windowsHide: true }).trim().split(/\s+/).map(Number).filter(Number.isSafeInteger)
|
|
assert.equal(pids.length, 1); process.kill(pids[0]); await new Promise(done => setTimeout(done, 40))
|
|
} }).then(() => null, error => error as Error & { transactionId?: string; recoveryRequired?: boolean })
|
|
check('native backend death reports recovery-required after a partial batch', killed?.recoveryRequired && !!killed.transactionId && await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-after')
|
|
await restoreTransaction(killed!.transactionId!, workspace)
|
|
check('a new native session recovers a batch after actual backend death', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && await fs.readFile(join(workspace, 'second.txt'), 'utf8') === 'second-before')
|
|
const handoff = await commitChanges(workspace, [{ path: 'first.txt', beforeHash: digest(Buffer.from('first-before')), after: Buffer.from('handoff-after') }])
|
|
const manifestPath = join(stateRoot, 'transactions', handoff, 'manifest.json'), manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'))
|
|
const intent = manifest.mutations[0], oldScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-old.tmp`), newScratch = join(workspace, `.ycode-sandbox-${intent.nonce}-new.tmp`)
|
|
await fs.unlink(join(workspace, 'first.txt')); await fs.writeFile(oldScratch, 'first-before'); await fs.writeFile(newScratch, 'handoff-after')
|
|
manifest.status = 'applying'; manifest.mutations = []; await fs.writeFile(manifestPath, JSON.stringify(manifest))
|
|
await restoreTransaction(handoff, workspace)
|
|
check('interrupted-handoff disk fixture restores absent basename and cleans verified scratch', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before' && !await fs.stat(oldScratch).catch(() => null) && !await fs.stat(newScratch).catch(() => null))
|
|
const blocked = await NativeWorkspaceWriter.create(workspace)
|
|
try {
|
|
const nonce = randomUUID().replaceAll('-', ''), scratch = join(workspace, `.ycode-sandbox-${nonce}-old.tmp`)
|
|
await fs.writeFile(scratch, 'foreign-content')
|
|
await assert.rejects(blocked.cleanup('first.txt', nonce, digest(Buffer.from('first-before')), null), /changed externally/)
|
|
check('scratch cleanup refuses unrelated or externally changed contents', await fs.readFile(scratch, 'utf8') === 'foreign-content')
|
|
} finally { await blocked.close() }
|
|
const dying = await NativeWorkspaceWriter.create(workspace)
|
|
const child = Reflect.get(dying, 'child') as ChildProcess
|
|
child.kill(); await new Promise(done => setTimeout(done, 30))
|
|
await assert.rejects(dying.replace('first.txt', digest(Buffer.from('first-before')), Buffer.from('never')))
|
|
await dying.close()
|
|
check('dead native channel cannot fall back to an ordinary host write', await fs.readFile(join(workspace, 'first.txt'), 'utf8') === 'first-before')
|
|
const cliRoot = join(fixture, 'CLI work \u4e2d\u6587 ^'); await fs.mkdir(cliRoot); await fs.writeFile(join(cliRoot, 'sample.txt'), 'cli-before')
|
|
const cliId = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('cli-after') }])
|
|
execFileSync(process.execPath, ['scripts/sandbox-maintain.mjs', 'restore', cliId, '--workspace', cliRoot, '--state-root', stateRoot], { windowsHide: true, timeout: 30_000, stdio: 'pipe' })
|
|
check('actual maintenance CLI restores v3 at a space Unicode caret path', await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before')
|
|
for (const version of [1, 2]) {
|
|
const id = await commitChanges(cliRoot, [{ path: 'sample.txt', beforeHash: digest(Buffer.from('cli-before')), after: Buffer.from('legacy-after') }])
|
|
const path = join(stateRoot, 'transactions', id, 'manifest.json'), legacy = JSON.parse(await fs.readFile(path, 'utf8'))
|
|
legacy.version = version; delete legacy.workspaceIdentity; delete legacy.mutationJournal; delete legacy.mutations
|
|
if (version === 1) delete legacy.directories
|
|
await fs.writeFile(path, JSON.stringify(legacy))
|
|
await restoreTransaction(id, cliRoot)
|
|
const upgraded = JSON.parse(await fs.readFile(path, 'utf8'))
|
|
check(`legacy v${version} recovery uses native backend and upgrades root identity`, await fs.readFile(join(cliRoot, 'sample.txt'), 'utf8') === 'cli-before' && upgraded.version === 3 && !!upgraded.workspaceIdentity)
|
|
}
|
|
console.log(`SANDBOX_WRITE_PASS ${checks.length}/${checks.length}`)
|
|
} finally {
|
|
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n')
|
|
console.log(`SANDBOX_WRITE_REPORT ${join(fixture, 'report.json')}`)
|
|
}
|