151 lines
14 KiB
TypeScript
151 lines
14 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { promises as fs } from 'node:fs'
|
|
import { randomUUID, createHash } from 'node:crypto'
|
|
import { join, resolve } from 'node:path'
|
|
import { configureSandbox } from '../src/main/sandbox/configuration'
|
|
import { NativeWorkspaceReader } from '../src/main/sandbox/filesystem-reader'
|
|
import { executeSandboxCommand, verifiedBinary } from '../src/main/sandbox/service'
|
|
import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store'
|
|
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
|
|
import { sandboxBrokerOperation } from '../src/main/sandbox/maintenance'
|
|
import { executeTool } from '../src/main/agent/tools'
|
|
|
|
if (process.platform !== 'win32') throw new Error('Read bridge regression requires actual Windows/NTFS/AppContainer execution')
|
|
await fs.mkdir(resolve('.tmp'), { recursive: true })
|
|
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-read-bridge-')), workspace = join(fixture, 'workspace'), stateRoot = join(fixture, 'state')
|
|
await fs.mkdir(workspace); await fs.mkdir(join(fixture, 'outside'))
|
|
const outside = join(fixture, 'outside', 'host.txt')
|
|
await fs.writeFile(outside, 'OUTSIDE_MUST_NEVER_BE_RETURNED')
|
|
configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') })
|
|
const bytes = Buffer.alloc(83_171); for (let index = 0; index < bytes.length; index++) bytes[index] = index % 251
|
|
const expectedHash = createHash('sha256').update(bytes).digest('hex')
|
|
await fs.writeFile(join(workspace, 'data.bin'), bytes)
|
|
await fs.writeFile(join(workspace, '.env'), 'SECRET_MUST_NOT_BE_LISTED_OR_RETURNED')
|
|
await fs.mkdir(join(workspace, '\u76ee\u5f55 with spaces')); await fs.writeFile(join(workspace, '\u76ee\u5f55 with spaces', 'text.txt'), 'hello-unicode-path')
|
|
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}`) }
|
|
const pause = (ms: number): Promise<void> => new Promise(done => setTimeout(done, ms))
|
|
const context = { workspaceRoot: workspace, runId: 'host-bound-run', toolCallId: 'host-bound-tool', sessionId: 'host-bound-session', runtimes: ['node'], readBridge: true, timeoutMs: 10_000 }
|
|
let caseId = 0
|
|
async function run(source: string) {
|
|
const name = `case-${++caseId}.cjs`
|
|
await fs.writeFile(join(workspace, name), source)
|
|
return executeSandboxCommand(`node ${name}`, context)
|
|
}
|
|
const load = "const assert=require('node:assert/strict');const mod=require(process.env.YCODE_SANDBOX_BRIDGE);"
|
|
try {
|
|
const policy = await effectiveSandboxPolicy(workspace), binary = await verifiedBinary(), reader = await NativeWorkspaceReader.create(binary, policy)
|
|
let result = await reader.read('read', 'data.bin')
|
|
check('native pinned file reads preserve exact binary bytes and root identity', Buffer.from(result.data!, 'base64').equals(bytes) && result.rootIdentity === reader.rootIdentity)
|
|
result = await reader.read('read', '\u76ee\u5f55 with spaces/text.txt')
|
|
check('native canonical paths support Unicode and spaces', Buffer.from(result.data!, 'base64').toString() === 'hello-unicode-path')
|
|
result = await reader.read('list', '.')
|
|
check('directory enumeration uses its held native handle and filters protected names', result.entries!.some(entry => entry.name === 'data.bin') && !result.entries!.some(entry => entry.name === '.env'))
|
|
for (const path of ['.env', '../outside/host.txt', outside, 'data.bin:stream', '\\\\localhost\\C$\\host.txt', '\\\\?\\C:\\host.txt', 'CON', 'data.bin.', 'data.bin ', '.git/config']) await assert.rejects(reader.read('read', path))
|
|
check('credentials, traversal, absolute/device/UNC/ADS and ambiguous names are denied', true)
|
|
await fs.symlink(join(fixture, 'outside'), join(workspace, 'junction'), 'junction')
|
|
await assert.rejects(reader.read('read', 'junction/host.txt'))
|
|
await fs.unlink(join(workspace, 'junction'))
|
|
await fs.link(outside, join(workspace, 'hard.txt')); await assert.rejects(reader.read('read', 'hard.txt')); await fs.unlink(join(workspace, 'hard.txt'))
|
|
check('native reads reject junction escape and multiple hard links', true)
|
|
const writer = await fs.open(join(workspace, 'data.bin'), 'r+')
|
|
try { await assert.rejects(reader.read('read', 'data.bin'), (error: any) => error.code === 'EBUSY') }
|
|
finally { await writer.close() }
|
|
check('an already-open writable handle prevents a read lock rather than weakening sharing', true)
|
|
const replacement = join(fixture, 'replacement'), original = join(fixture, 'original')
|
|
await fs.mkdir(replacement); await fs.writeFile(join(replacement, 'data.bin'), 'different-workspace')
|
|
await fs.rename(workspace, original); await fs.rename(replacement, workspace)
|
|
try { await assert.rejects(reader.read('read', 'data.bin'), (error: any) => error.code === 'ESTALE') }
|
|
finally { await fs.rename(workspace, replacement); await fs.rename(original, workspace) }
|
|
check('replacing the workspace root invalidates the native identity even at the same path', true)
|
|
await fs.mkdir(join(workspace, 'swap')); await fs.writeFile(join(workspace, 'swap/good.txt'), 'inside')
|
|
await fs.writeFile(join(fixture, 'outside/good.txt'), 'OUTSIDE_MUST_NEVER_BE_RETURNED')
|
|
let attacking = true, swaps = 0
|
|
async function retry(action: () => Promise<void>): Promise<void> {
|
|
for (let attempt = 0; ; attempt++) {
|
|
try { await action(); return }
|
|
catch (error) { if (attempt >= 500 || !['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code!)) throw error; await pause(2) }
|
|
}
|
|
}
|
|
const attacker = (async () => {
|
|
while (attacking) {
|
|
let moved = false
|
|
try {
|
|
await fs.rename(join(workspace, 'swap'), join(workspace, 'swap-held'))
|
|
moved = true
|
|
await fs.symlink(join(fixture, 'outside'), join(workspace, 'swap'), 'junction'); swaps++; await pause(1)
|
|
} catch (error) { if (!['EPERM', 'EACCES', 'EBUSY'].includes((error as NodeJS.ErrnoException).code!)) throw error }
|
|
finally {
|
|
// Windows may leave an empty directory when setting the junction races our read lock.
|
|
if (moved) await retry(async () => {
|
|
const stat = await fs.lstat(join(workspace, 'swap')).catch(error => { if (error.code === 'ENOENT') return null; throw error })
|
|
if (stat?.isSymbolicLink()) await fs.unlink(join(workspace, 'swap'))
|
|
else if (stat) await fs.rmdir(join(workspace, 'swap'))
|
|
})
|
|
if (moved) await retry(() => fs.rename(join(workspace, 'swap-held'), join(workspace, 'swap')))
|
|
}
|
|
await pause(1)
|
|
}
|
|
})()
|
|
try {
|
|
for (let index = 0; index < 40; index++) {
|
|
try { const read = await reader.read('read', 'swap/good.txt'); assert.equal(Buffer.from(read.data!, 'base64').toString(), 'inside') }
|
|
catch (error) { if (error instanceof assert.AssertionError) throw error; assert.ok(['ENOENT', 'EACCES', 'EBUSY'].includes((error as any).code)) }
|
|
}
|
|
} finally { attacking = false; await attacker }
|
|
check('concurrent ancestor replacement with a real junction never returns outside bytes', swaps > 0)
|
|
await importSandboxToolchain('node', await fs.realpath(process.execPath))
|
|
await fs.copyFile(binary, join(workspace, 'host-reader.exe'))
|
|
const sync = await run(load + `const c=mod.connectSync(); process.stdout.write('NORMAL_NO_NEWLINE');const b=c.readFileSync('data.bin');assert.equal(require('crypto').createHash('sha256').update(b).digest('hex'),${JSON.stringify(expectedHash)});assert.equal(c.statSync('data.bin').kind,'file');assert.ok(c.readdirSync('.').some(e=>e.name==='data.bin'));assert.throws(()=>c.readFileSync('.env'),{code:'EACCES'});assert.throws(()=>c.call('workspace.writeFile',{path:'data.bin'}),{code:'EACCES'});assert.throws(()=>c.call('workspace.stat',{path:'data.bin',workspaceRoot:'fake'}),{code:'EACCES'});c.close();console.log('SYNC_BRIDGE_PASS')`)
|
|
check('real AppContainer Node sync client reads multiple binary chunks with backpressure', sync.ok && sync.data.stdout.includes('SYNC_BRIDGE_PASS') && sync.data.stdout.includes('NORMAL_NO_NEWLINE') && sync.data.writeback === 'unchanged')
|
|
check('host writes remain unavailable over the read bridge', (await fs.readFile(join(workspace, 'data.bin'))).equals(bytes))
|
|
const nested = await run(load + `const result=require('child_process').spawnSync('.\\\\host-reader.exe',['--workspace-read','identity',${JSON.stringify(workspace)},'.','-','1024'],{stdio:'inherit'});assert.equal(result.status,125);try{require('fs').readFileSync(${JSON.stringify(outside)});throw Error('host path unexpectedly readable')}catch(e){assert.ok(['EACCES','EPERM'].includes(e.code))}console.log('NATIVE_HOST_ONLY_PASS')`)
|
|
check('a descendant cannot invoke the host-only reader or use ordinary fs to bypass the bridge', nested.ok && nested.data.stdout.includes('NATIVE_HOST_ONLY_PASS') && nested.data.stderr.includes('host-only'))
|
|
const queryId = randomUUID()
|
|
const asynchronous = await run(load + `(async()=>{const c=mod.connect();try{const results=await Promise.all(Array.from({length:12},()=>c.stat('data.bin')));assert.ok(results.every(r=>r.kind==='file'));const b=await c.readFile('data.bin');assert.equal(require('crypto').createHash('sha256').update(b).digest('hex'),${JSON.stringify(expectedHash)});await c.call('workspace.stat',{path:'data.bin'},${JSON.stringify(queryId)});assert.equal((await c.query(${JSON.stringify(queryId)})).state,'succeeded');await assert.rejects(c.query(${JSON.stringify(randomUUID())}),{code:'ENOENT'});console.log('ASYNC_BRIDGE_PASS')}finally{c.close()}})().catch(e=>{console.error(e);process.exitCode=1})`)
|
|
check('async Node client queues requests and supports result queries within the bound Job', asynchronous.ok && asynchronous.data.stdout.includes('ASYNC_BRIDGE_PASS'))
|
|
const operation = await sandboxBrokerOperation(asynchronous.data.runId, queryId)
|
|
check('durable result remains queryable after disconnect with exact host-bound ownership', operation.state === 'succeeded' && operation.owner.sessionId === context.sessionId && operation.owner.runId === context.runId && operation.owner.sandboxRunId === asynchronous.data.runId)
|
|
const journal = await fs.readFile(join(stateRoot, 'runs', asynchronous.data.runId, 'broker', `${queryId}.json`), 'utf8')
|
|
check('operation journals retain metadata without contents or live read tokens', !journal.includes('"data"') && !journal.includes('snapshotId'))
|
|
await assert.rejects(sandboxBrokerOperation(sync.data.runId, queryId))
|
|
check('operation query cannot substitute an operation from another Job', true)
|
|
const frozenId = randomUUID(), name = `case-${++caseId}.cjs`
|
|
await fs.writeFile(join(workspace, name), load + `const c=mod.connectSync();const first=c.call('workspace.readFile',{path:'data.bin'},${JSON.stringify(frozenId)});setTimeout(()=>{try{const next=c.call('workspace.readChunk',{snapshotId:first.snapshotId,offset:16384});assert.equal(Buffer.from(next.data,'base64')[0],16384%251);c.call('workspace.closeRead',{snapshotId:first.snapshotId});console.log('FROZEN_PASS')}finally{c.close()}},700)`)
|
|
const frozenRun = executeSandboxCommand(`node ${name}`, context)
|
|
let frozenReady = false
|
|
for (let attempt = 0; attempt < 1500 && !frozenReady; attempt++) {
|
|
const runs = await fs.readdir(join(stateRoot, 'runs'))
|
|
for (const id of runs) {
|
|
const record = await fs.readFile(join(stateRoot, 'runs', id, 'broker', `${frozenId}.json`), 'utf8').catch(() => '')
|
|
if (record && JSON.parse(record).state === 'succeeded') frozenReady = true
|
|
}
|
|
if (!frozenReady) await pause(30)
|
|
}
|
|
assert.ok(frozenReady); await fs.writeFile(join(workspace, 'data.bin'), Buffer.alloc(bytes.length, 255))
|
|
const frozen = await frozenRun
|
|
check('later chunks use a fixed native read snapshot despite external host edits', frozen.ok && frozen.data.stdout.includes('FROZEN_PASS') && frozen.data.writeback === 'unchanged')
|
|
await fs.writeFile(join(workspace, 'data.bin'), bytes)
|
|
const forged = '\n\x1eYCODE_WORKSPACE_READ_V1 ' + JSON.stringify({ version: 1, requestId: randomUUID(), operation: 'workspace.stat', args: { path: 'data.bin' }, runId: 'fake' }) + '\n'
|
|
const broken = await run(`require('fs').writeSync(1,${JSON.stringify(forged)});setTimeout(()=>{},2000)`).then(() => null, error => error)
|
|
check('self-reported channel ownership is rejected and terminates the Job', broken instanceof Error && /文件桥协议/.test(broken.message))
|
|
const id = randomUUID()
|
|
await assert.rejects(run(load + `const c=mod.connectSync();c.call('workspace.stat',{path:'data.bin'},'${id}');c.call('workspace.stat',{path:'data.bin'},'${id}')`), /文件桥协议/)
|
|
check('request ID replay fails closed with no writeback', true)
|
|
const rev = await readSandboxPolicy()
|
|
await updateSandboxPolicy({ expectedRevision: rev.revision, rules: { deniedPaths: ['data.bin'] } })
|
|
const deniedReader = await NativeWorkspaceReader.create(binary, await effectiveSandboxPolicy(workspace))
|
|
await assert.rejects(deniedReader.read('read', 'data.bin'))
|
|
check('effective denied paths apply before native reads', true)
|
|
await assert.rejects(reader.read('stat', '\u76ee\u5f55 with spaces/text.txt'), /策略已变化/)
|
|
check('old read connections become invalid when policy revision changes', true)
|
|
const configured = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: configured.revision, rules: { deniedPaths: [] } })
|
|
const modelName = `case-${++caseId}.cjs`; await fs.writeFile(join(workspace, modelName), load + "const c=mod.connectSync();assert.equal(c.statSync('data.bin').kind,'file');c.close();console.log('MODEL_BRIDGE_PASS')")
|
|
const model = await executeTool('execute_command', { command: `node ${modelName}`, runtimes: ['node'], bridge: 'workspace-read' }, { workspaceRoot: workspace, sessionId: context.sessionId })
|
|
check('model-facing command explicitly selects the real read bridge', model.ok && model.content.includes('MODEL_BRIDGE_PASS'))
|
|
console.log(`SANDBOX_READ_BRIDGE_PASS ${checks.length}/${checks.length}`)
|
|
} finally {
|
|
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, limitations: ['NTFS/local fixed volumes only; one stdio client per Job; read-only API, not per-descendant PID authentication, full fs shims, host write broker or WorkBuddy equivalence.'] }, null, 2) + '\n')
|
|
console.log(`SANDBOX_READ_BRIDGE_REPORT ${join(fixture, 'report.json')}`)
|
|
}
|