import assert from 'node:assert/strict' import { promises as fs } from 'node:fs' import { execFileSync, spawn } from 'node:child_process' import { randomBytes, randomUUID } from 'node:crypto' import { fileURLToPath } from 'node:url' import { join, resolve, relative, isAbsolute } from 'node:path' import { configureSandbox } from '../src/main/sandbox/configuration' import { gitHostAction, hostGitStatus } from '../src/main/sandbox/git-host-service' import { effectiveSandboxPolicy, readSandboxPolicy, updateSandboxPolicy } from '../src/main/sandbox/policy-store' import { hostGitRoot, loadHostGitBinding } from '../src/main/sandbox/git-host-source' import { executeSandboxCommand } from '../src/main/sandbox/service' import { executeTool } from '../src/main/agent/tools' import { modeAllowsTool } from '../src/main/agent/mode-policy' import { workspaceAction } from '../src/main/sandbox/workspace-service' import { cleanupGitControls, createGitControl } from '../src/main/sandbox/git-control' import { withSandboxExecution } from '../src/main/sandbox/execution' import { PersistentWorkspace, workspaceKey } from '../src/main/sandbox/workspace' if (process.argv[2] === '--worker-commit') { configureSandbox({ stateRoot: process.argv[4], resourceDirectory: resolve('resources/sandbox/win32-x64') }) await gitHostAction('commit', { workspaceRoot: process.argv[3], sessionId: process.argv[5], approve: async () => true }) process.exit(0) } const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-git-safety-')), workspace = join(fixture, 'project \u4e2d\u6587 ^'), stateRoot = join(fixture, 'state') await fs.mkdir(workspace); await fs.mkdir(stateRoot) configureSandbox({ stateRoot, resourceDirectory: resolve('resources/sandbox/win32-x64') }) const git = resolve('resources/sandbox/win32-x64/git/files/git.exe'), tool = resolve('resources/sandbox/win32-x64/git/files') const env = { SystemRoot: process.env.SystemRoot, PATH: `${tool};${process.env.SystemRoot}\\System32`, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: 'NUL', GIT_CONFIG_SYSTEM: 'NUL', GIT_TERMINAL_PROMPT: '0' } const hostGit = (...args: string[]): string => execFileSync(git, ['-c', 'core.hooksPath=NUL', '-c', 'core.fsmonitor=false', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', ...args], { cwd: workspace, env, encoding: 'utf8', windowsHide: true }) const context = { workspaceRoot: workspace, sessionId: 'safety-session', runId: 'safety-run', toolCallId: 'safety-call', approve: async () => true } 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 run = (command: string) => executeSandboxCommand(command, { ...context, runtimes: ['git'], workspaceMode: 'persistent' }) async function savedJson(path: string): Promise { return fs.readFile(path, 'utf8').then(JSON.parse, error => { if (error.code === 'ENOENT') return null; throw error }) } try { hostGit('init', '-q', '--template=', '--initial-branch=main') await fs.writeFile(join(workspace, 'file.txt'), 'base\n'); hostGit('add', '.'); hostGit('commit', '-q', '-m', 'base') const rejected = await gitHostAction('import', { ...context, approve: async () => false }) check('denied history import creates no active host binding', rejected.writeback === 'rejected' && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported') let prompt = '' await assert.rejects(gitHostAction('import', { ...context, approve: async title => { prompt = title; hostGit('update-ref', 'refs/heads/concurrent-import', 'HEAD'); return true } }), /changed while import/) check('import approval is invalidated by an external reference change', prompt.includes('历史可能包含') && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported') await gitHostAction('import', context) const priorRevision = (await workspaceAction('status', context)).revision! let releaseQueue!: () => void, enteredQueue!: () => void const queueEntered = new Promise(done => { enteredQueue = done }), queueRelease = new Promise(done => { releaseQueue = done }) const queuedMutation = withSandboxExecution(workspace, undefined, async () => { enteredQueue(); await queueRelease const policy = await effectiveSandboxPolicy(workspace), binding = await loadHostGitBinding(policy, context.sessionId) const draft = await PersistentWorkspace.open(policy, context.sessionId, binding!.layout.projectIdentity) try { await fs.writeFile(join(workspace, 'queued-external.txt'), 'queued-host-edit\n'); await draft.synchronize() } finally { await draft.close() } }) await queueEntered const staleDiscard = workspaceAction('discard', context, priorRevision) const staleDiscardCheck = assert.rejects(staleDiscard, /版本已变化/) await new Promise(done => setTimeout(done, 100)); releaseQueue(); await queuedMutation; await staleDiscardCheck check('workspace actions recheck their expected revision after waiting in the shared Git queue', (await workspaceAction('status', context)).revision !== priorRevision) const nativeTool = await executeTool('sandbox_git', { action: 'run', argv: ['status', '--short'] }, { ...context, permissionMode: 'full-access' }) check('explicit Git tool keeps native confinement in a full-access context', nativeTool.ok && (nativeTool.data as { isolated: boolean }).isolated) check('model tool rejects forged project/session ownership', !(await executeTool('sandbox_git', { action: 'status', workspaceRoot: fixture, sessionId: 'other' }, context)).ok) check('planning mode cannot mutate or import Git state', !modeAllowsTool('plan', 'sandbox_git') && !modeAllowsTool('ask', 'sandbox_git')) const defaultResult = await executeSandboxCommand('git log -1 --oneline', { ...context, runtimes: ['git'] }) check('ordinary Git runtime calls select the imported session mirror after import', defaultResult.ok && defaultResult.data.git?.mode === 'host-mirror' && defaultResult.data.workspace?.mode === 'persistent') await run('echo pending>file.txt && git add file.txt && git commit -q -m pending') const original = hostGit('rev-parse', 'HEAD').trim() await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { hostGit('update-ref', 'refs/heads/raced', 'HEAD'); return true } }), /changed while commit approval/) check('host metadata edits during commit approval prevent every host file write', hostGit('rev-parse', 'HEAD').trim() === original && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')) === 'base\n') const refreshed = await executeSandboxCommand('git show-ref raced && git log -1 --oneline', { ...context, runtimes: ['git'], workspaceMode: 'persistent' }) check('independent external reference updates merge without losing private commits', refreshed.ok && refreshed.data.stdout.includes('raced') && refreshed.data.stdout.includes('pending')) await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { await fs.chmod(join(workspace, '.git/index'), 0o444); return true } }), /read.only|readonly|原生|Transaction/i) await fs.chmod(join(workspace, '.git/index'), 0o666) check('a metadata-phase failure compensates the already committed worktree phase', (await fs.readFile(join(workspace, 'file.txt'), 'utf8')) === 'base\n' && hostGit('rev-parse', 'HEAD').trim() === original) const bindingRoot = hostGitRoot(workspace, context.sessionId) check('compensated failure clears only its owned Git coordination locks', !await fs.lstat(join(workspace, '.git/index.lock')).catch(() => null) && !await fs.lstat(join(bindingRoot, 'commit.json')).catch(() => null)) const partial = await gitHostAction('commit', { ...context, approve: async () => { await fs.rename(join(bindingRoot, 'binding.json'), join(bindingRoot, 'binding-before-fault.json')); await fs.mkdir(join(bindingRoot, 'binding.json')); return true } }) check('private metadata failure after real host writes retains a committed outcome', partial.writeback === 'committed' && partial.state === 'interrupted' && hostGit('log', '-1', '--oneline').includes('pending') && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).includes('pending')) const committedHead = hostGit('rev-parse', 'HEAD').trim(), beforeRecoveryTransactions = (await fs.readdir(join(stateRoot, 'transactions'))).length await fs.rmdir(join(bindingRoot, 'binding.json')); await fs.rename(join(bindingRoot, 'binding-before-fault.json'), join(bindingRoot, 'binding.json')) const recovered = await gitHostAction('recover', context) check('group recovery finishes bookkeeping without replaying committed host mutations', recovered.writeback === 'committed' && recovered.state === 'ready' && hostGit('rev-parse', 'HEAD').trim() === committedHead && (await fs.readdir(join(stateRoot, 'transactions'))).length === beforeRecoveryTransactions) await run('echo root-swap>file.txt && git add file.txt && git commit -q -m root-swap') const backup = join(workspace, '.git-before-swap') await assert.rejects(gitHostAction('commit', { ...context, approve: async () => { await fs.rename(join(workspace, '.git'), backup); await fs.cp(backup, join(workspace, '.git'), { recursive: true }); return true } }), /changed/) check('same-content .git directory replacement invalidates the approved root identity', hostGit('rev-parse', 'HEAD').trim() === committedHead && (await fs.readFile(join(workspace, 'file.txt'), 'utf8')).includes('pending')) const replacement = resolve(workspace, '.git'), rel = relative(fixture, replacement) assert.ok(rel && !rel.startsWith('..') && !isAbsolute(rel)); await fs.rm(replacement, { recursive: true }); await fs.rename(backup, replacement) await gitHostAction('discard', context) await run('git commit --allow-empty -q -m cleanup-acknowledgement') const workspaceLock = join(stateRoot, 'workspaces', workspaceKey(workspace, context.sessionId), 'workspace.lock') const alteredLockNonce = randomUUID() const cleanupFailure = await gitHostAction('commit', { ...context, approve: async () => { await fs.writeFile(workspaceLock, JSON.stringify({ pid: process.pid, nonce: alteredLockNonce }) + '\n'); return true } }) await fs.writeFile(join(fixture, 'cleanup-failure-result.json'), JSON.stringify(cleanupFailure, null, 2)) check('a lock cleanup failure never replaces an already committed host outcome', cleanupFailure.writeback === 'committed' && cleanupFailure.state === 'interrupted' && /cleanup requires maintenance/.test(cleanupFailure.reason ?? '') && hostGit('log', '-1', '--oneline').includes('cleanup-acknowledgement')) assert.equal((await savedJson(workspaceLock)).nonce, alteredLockNonce); await fs.unlink(workspaceLock) for (let index = 0; index < 20; index++) await fs.writeFile(join(workspace, `large-${index}.bin`), randomBytes(512 * 1024)) await run('echo crash-draft>file.txt && git add . && git commit -q -m crash-draft && git gc --quiet') const beforeCrashHead = hostGit('rev-parse', 'HEAD').trim(), beforeCrashBytes = await fs.readFile(join(workspace, 'file.txt')) const worker = spawn(process.execPath, [fileURLToPath(import.meta.url), '--worker-commit', workspace, stateRoot, context.sessionId], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) let workerError = ''; worker.stderr.on('data', (data: Buffer) => { workerError = (workerError + data.toString()).slice(-4000) }) const closed = new Promise((done, reject) => { worker.once('error', reject); worker.once('close', () => done()) }) let interrupted = false for (let attempt = 0; attempt < 2500; attempt++) { const group = await savedJson(join(bindingRoot, 'commit.json')) if (group?.status === 'applying' && group.transactions?.worktree && !group.transactions.common) { interrupted = true; worker.kill(); break } if (worker.exitCode !== null) break await new Promise(done => setTimeout(done, 5)) } if (!interrupted) { worker.kill(); await closed; throw new Error(`Did not observe the real mid-group interruption boundary: ${workerError}`) } await closed let crashRecovery: Awaited> | undefined for (let attempt = 0; attempt < 50; attempt++) { try { crashRecovery = await gitHostAction('recover', context); break } catch (error) { if (!/active|正在|共享|sharing|win32=32/i.test((error as Error).message) || attempt === 49) throw error; await new Promise(done => setTimeout(done, 50)) } } check('actual host-process death between native phases recovers a determinate outcome', !!crashRecovery && ['not-applied', 'committed'].includes(crashRecovery.writeback ?? '')) if (crashRecovery?.writeback === 'not-applied') check('interrupted multi-root publication restores the original host files and refs', hostGit('rev-parse', 'HEAD').trim() === beforeCrashHead && (await fs.readFile(join(workspace, 'file.txt'))).equals(beforeCrashBytes)) else check('an already completed group is acknowledged without compensating its committed state', hostGit('log', '-1', '--oneline').includes('crash-draft')) check('recovered private Git history remains available for explicit retry or inspection', (await run('git log -1 --oneline')).data.stdout.includes('crash-draft')) const cleanup = await cleanupGitControls() check('Git maintenance removes only abandoned controls after verifying owner/native lifetimes', cleanup.some(record => record.state === 'removed')) const liveControl = await createGitControl(await effectiveSandboxPolicy(workspace)) try { check('maintenance preserves a live owned Git control', (await cleanupGitControls()).some(record => record.id === liveControl.root.split(/[\\/]/).at(-1) && record.state === 'active')) } finally { await liveControl.close() } if (crashRecovery?.writeback === 'not-applied') await gitHostAction('commit', context) check('a real pack larger than the ordinary 8 MiB file ceiling publishes through the Git metadata channel', hostGit('log', '-1', '--oneline').includes('crash-draft') && (await fs.readdir(join(workspace, '.git/objects/pack'))).some(name => name.endsWith('.pack') && name.startsWith('pack-')) && hostGit('fsck', '--connectivity-only').trim() === '') const foreignLock = join(workspace, '.git/index.lock'); await fs.writeFile(foreignLock, 'foreign-lock') await assert.rejects(run('git status --short'), /busy|lock/) check('a foreign Git lock is never consumed or removed by refresh', (await fs.readFile(foreignLock, 'utf8')) === 'foreign-lock') await fs.unlink(foreignLock) const policy = await readSandboxPolicy(); await updateSandboxPolicy({ expectedRevision: policy.revision, rules: { deniedPaths: ['secret.txt'] } }) check('policy revocation makes the full history import stale', (await hostGitStatus(workspace, context.sessionId)).state === 'stale') await assert.rejects(run('git log -1'), /策略|policy/) const discarded = await workspaceAction('discard', context) check('discard under a new policy archives the old full-history grant without silently reimporting it', discarded.writeback === 'not-applied' && (await hostGitStatus(workspace, context.sessionId)).state === 'unimported') const currentPolicy = await effectiveSandboxPolicy(workspace) check('the active policy remains in force after Git recovery and discard', currentPolicy.deniedPaths.includes('secret.txt')) console.log(`SANDBOX_GIT_SAFETY_PASS ${checks.length}/${checks.length}`) } finally { await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks }, null, 2) + '\n') console.log(`SANDBOX_GIT_SAFETY_REPORT ${join(fixture, 'report.json')}`) }