import { promises as fs } from 'node:fs' import { createHash, randomUUID } from 'node:crypto' import { deflate } from 'node:zlib' import { promisify } from 'node:util' import { dirname, isAbsolute, join, relative } from 'node:path' import type { SandboxEffectivePolicy, SandboxGitState } from '@shared/sandbox' import { sandboxConfiguration } from './configuration' import { assertActive, protectedPath, within } from './policy' import { assertPolicyCurrent } from './policy-store' import { sealWorkspace } from './workspace' interface GitFile { path: string; bytes: number; sha256: string } export interface GitIdentity { id: string; manifestSha256: string } export interface GitManifest { version: 1; id: string; workspaceRoot: string; policyFingerprint: string createdAt: number; contentHash: string; files: GitFile[]; bytes: number origin?: 'host' | 'remote' | 'empty' } export interface SandboxGitSession { state: SandboxGitState capture(signal?: AbortSignal): Promise assertCurrent(): Promise publish(candidate: GitIdentity): Promise close(): Promise } const limits = { files: 30_000, bytes: 256 * 1024 * 1024, fileBytes: 128 * 1024 * 1024, manifestBytes: 8 * 1024 * 1024 } const hash = (bytes: Buffer | string): string => createHash('sha256').update(bytes).digest('hex') const sha1 = (bytes: Buffer): Buffer => createHash('sha1').update(bytes).digest() const compress = promisify(deflate) const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ const hashPattern = /^[a-f0-9]{64}$/ const key = (value: string): string => process.platform === 'win32' ? value.toLowerCase() : value const repositoryRoot = (workspace: string, namespace?: string): string => { if (namespace !== undefined && !hashPattern.test(namespace)) throw new Error('Invalid sandbox Git draft namespace') return join(sandboxConfiguration().stateRoot, 'git', hash(key(workspace)) + (namespace ? `-${namespace}` : '')) } async function directory(path: string): Promise { const stat = await fs.lstat(path) if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('Sandbox Git state directory must not be a link') } async function regular(path: string, maximum = limits.fileBytes): Promise { const stat = await fs.lstat(path) if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > maximum) throw new Error('Sandbox Git metadata contains a link, special file or oversized file') const handle = await fs.open(path, 'r') try { const before = await handle.stat() if (before.ino !== stat.ino || before.dev !== stat.dev || before.nlink !== 1 || before.size !== stat.size) throw new Error('Sandbox Git file was replaced before reading') const bytes = await handle.readFile(), after = await handle.stat() if (bytes.length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs) throw new Error('Sandbox Git file changed during reading') return bytes } finally { await handle.close() } } function filePath(value: unknown): string { if (typeof value !== 'string' || !value || value.length > 2048 || isAbsolute(value) || value.includes('\\') || value.split('/').some(part => !part || part === '.' || part === '..') || protectedPath(value) || /(?:^|\/)(?:commondir|gitdir|alternates|http-alternates)$/.test(value) || /\.lock$/i.test(value)) throw new Error('Unsupported or unsafe sandbox Git metadata path') return value } async function prepareRoot(workspace: string, namespace?: string): Promise { const root = repositoryRoot(workspace, namespace) await fs.mkdir(join(root, 'objects'), { recursive: true, mode: 0o700 }) for (const path of [sandboxConfiguration().stateRoot, join(sandboxConfiguration().stateRoot, 'git'), root, join(root, 'objects')]) await directory(path) return root } async function activeIdentity(root: string): Promise { const path = join(root, 'active.json') if (!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return null const value = JSON.parse((await regular(path, 4096)).toString('utf8')) as GitIdentity if (!uuid.test(value.id) || !hashPattern.test(value.manifestSha256)) throw new Error('Invalid sandbox Git revision identity') return value } async function manifestAt(root: string, identity: GitIdentity, workspace: string): Promise { const object = join(root, 'objects', identity.id) await directory(object); await directory(join(object, 'files')) const bytes = await regular(join(object, 'manifest.json'), limits.manifestBytes) if (hash(bytes) !== identity.manifestSha256) throw new Error('Sandbox Git manifest digest mismatch') const value = JSON.parse(bytes.toString('utf8')) as GitManifest if (value.version !== 1 || value.id !== identity.id || typeof value.workspaceRoot !== 'string' || key(value.workspaceRoot) !== key(workspace) || !hashPattern.test(value.policyFingerprint) || value.origin !== undefined && !['host', 'remote', 'empty'].includes(value.origin) || !Array.isArray(value.files) || !value.files.length || value.files.length > limits.files) throw new Error('Invalid sandbox Git manifest ownership or format') let total = 0 const seen = new Set() for (const file of value.files) { const path = filePath(file.path) if (seen.has(key(path)) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > limits.fileBytes || !hashPattern.test(file.sha256)) throw new Error('Invalid sandbox Git file identity') seen.add(key(path)); total += file.bytes } if (!seen.has(key('HEAD')) || !seen.has('config') || value.bytes !== total || total > limits.bytes || value.contentHash !== hash(JSON.stringify(value.files))) throw new Error('Invalid sandbox Git metadata inventory') return value } async function scanRepository(source: string, signal?: AbortSignal): Promise<{ files: GitFile[]; bytes: number }> { const files: GitFile[] = [] let bytes = 0, entries = 0 async function walk(path: string, depth = 0): Promise { if (depth > 64) throw new Error('Sandbox Git metadata nesting exceeds the limit') await directory(path) for (const entry of await fs.readdir(path, { withFileTypes: true })) { assertActive(signal) if (++entries > limits.files) throw new Error('Sandbox Git metadata exceeds the entry limit') const absolute = join(path, entry.name), file = filePath(relative(source, absolute).replaceAll('\\', '/')) if (entry.isSymbolicLink()) throw new Error('Sandbox Git metadata cannot contain links') if (entry.isDirectory()) await walk(absolute, depth + 1) else { const content = await regular(absolute) bytes += content.length if (bytes > limits.bytes) throw new Error('Sandbox Git history exceeds the 256 MiB limit') files.push({ path: file, bytes: content.length, sha256: hash(content) }) } } } await walk(source) files.sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path))) return { files, bytes } } async function storeRepository(root: string, source: string, policy: SandboxEffectivePolicy, inventory: { files: GitFile[]; bytes: number }, signal?: AbortSignal, origin?: 'host' | 'remote' | 'empty'): Promise { const id = randomUUID(), object = join(root, 'objects', id), target = join(object, 'files') await fs.mkdir(target, { recursive: true, mode: 0o700 }) const manifest: GitManifest = { version: 1, id, workspaceRoot: policy.workspaceRoot, policyFingerprint: policy.fingerprint, createdAt: Date.now(), contentHash: hash(JSON.stringify(inventory.files)), ...(origin ? { origin } : {}), ...inventory } for (const file of inventory.files) { assertActive(signal) const content = await regular(join(source, file.path)) if (content.length !== file.bytes || hash(content) !== file.sha256) throw new Error('Sandbox Git metadata changed after capture') const destination = join(target, file.path) await fs.mkdir(dirname(destination), { recursive: true }) await fs.writeFile(destination, content, { flag: 'wx', mode: 0o600 }) } const bytes = Buffer.from(JSON.stringify(manifest) + '\n') if (bytes.length > limits.manifestBytes) throw new Error('Sandbox Git manifest exceeds the limit') const handle = await fs.open(join(object, 'manifest.json'), 'wx', 0o600) try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() } return { id, manifestSha256: hash(bytes) } } async function publishIdentity(root: string, identity: GitIdentity): Promise { const temporary = join(root, `active-${randomUUID()}.tmp`) try { const handle = await fs.open(temporary, 'wx', 0o600) try { await handle.writeFile(JSON.stringify(identity) + '\n'); await handle.sync() } finally { await handle.close() } await fs.rename(temporary, join(root, 'active.json')) } finally { await fs.unlink(temporary).catch(() => undefined) } } /** Build one synthetic commit from approved input bytes; never open the host .git. */ async function initializeRepository(stage: string, files: Map, signal?: AbortSignal): Promise { const target = join(stage, 'repository'), objects = new Set() await fs.mkdir(join(target, 'objects'), { recursive: true }) const put = async (name: string, bytes: Buffer | string): Promise => { const path = join(target, name) await fs.mkdir(dirname(path), { recursive: true }) await fs.writeFile(path, bytes, { flag: 'wx', mode: 0o600 }) } const object = async (type: string, bytes: Buffer): Promise => { const content = Buffer.concat([Buffer.from(`${type} ${bytes.length}\0`), bytes]), id = sha1(content), hex = id.toString('hex') if (!objects.has(hex)) { objects.add(hex); await put(`objects/${hex.slice(0, 2)}/${hex.slice(2)}`, await compress(content)) } return id } interface Tree { files: Map; directories: Map } const tree = (): Tree => ({ files: new Map(), directories: new Map() }), root = tree() const entries: Buffer[] = [] for (const [path, expected] of [...files].sort((a, b) => Buffer.compare(Buffer.from(a[0].replaceAll('\\', '/')), Buffer.from(b[0].replaceAll('\\', '/'))))) { assertActive(signal) const bytes = await regular(join(stage, 'workspace', path)) if (hash(bytes) !== expected.hash || bytes.length !== expected.size) throw new Error('Git baseline input changed') const id = await object('blob', bytes), name = path.replaceAll('\\', '/') const parts = name.split('/'); let parent = root for (const part of parts.slice(0, -1)) { if (!parent.directories.has(part)) parent.directories.set(part, tree()); parent = parent.directories.get(part)! } parent.files.set(parts.at(-1)!, id) const nameBytes = Buffer.from(name), entrySize = 62 + nameBytes.length + 1 const entry = Buffer.alloc(Math.ceil(entrySize / 8) * 8) entry.writeUInt32BE(0o100644, 24); entry.writeUInt32BE(bytes.length, 36); id.copy(entry, 40) entry.writeUInt16BE(Math.min(nameBytes.length, 0xfff), 60); nameBytes.copy(entry, 62) entries.push(entry) } const writeTree = async (value: Tree): Promise => { const entries: Array<{ name: string; mode: string; id: Buffer }> = [...value.files].map(([name, id]) => ({ name, mode: '100644', id })) for (const [name, child] of value.directories) entries.push({ name, mode: '40000', id: await writeTree(child) }) entries.sort((a, b) => Buffer.compare(Buffer.from(a.name + (a.mode === '40000' ? '/' : '')), Buffer.from(b.name + (b.mode === '40000' ? '/' : '')))) return object('tree', Buffer.concat(entries.map(entry => Buffer.concat([Buffer.from(`${entry.mode} ${entry.name}\0`), entry.id])))) } const rootTree = await writeTree(root), timestamp = Math.floor(Date.now() / 1000) const commit = await object('commit', Buffer.from(`tree ${rootTree.toString('hex')}\nauthor YCode Sandbox ${timestamp} +0000\ncommitter YCode Sandbox ${timestamp} +0000\n\nYCode sandbox baseline (filtered workspace snapshot; no host history)\n`)) const header = Buffer.alloc(12); header.write('DIRC'); header.writeUInt32BE(2, 4); header.writeUInt32BE(entries.length, 8) const index = Buffer.concat([header, ...entries]) await put('index', Buffer.concat([index, sha1(index)])) await put('HEAD', 'ref: refs/heads/ycode-sandbox\n') await put('refs/heads/ycode-sandbox', `${commit.toString('hex')}\n`) // The baseline records snapshot bytes, rather than host clean/smudge rules. await put('info/attributes', '* -text -filter -ident -eol -working-tree-encoding\n') await put('config', '[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tfilemode = false\n\tautocrlf = false\n\tignorecase = true\n\tsymlinks = false\n[user]\n\tname = YCode Sandbox\n\temail = sandbox@ycode.local\n') } export async function stageSandboxGit(stage: string, policy: SandboxEffectivePolicy, files: Map, signal?: AbortSignal, namespace?: string): Promise { const root = await prepareRoot(policy.workspaceRoot, namespace), lockPath = join(root, 'repository.lock') const lock = await fs.open(lockPath, 'wx', 0o600).catch(error => { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Sandbox Git is in use; inspect repository.lock if the previous host exited'); throw error }) const close = async (): Promise => { await lock.close(); await fs.unlink(lockPath) } try { await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n') let identity = await activeIdentity(root), initialized = false const target = join(stage, 'repository') if (!identity) { await initializeRepository(stage, files, signal) identity = await storeRepository(root, target, policy, await scanRepository(target, signal), signal) await assertPolicyCurrent(policy) await publishIdentity(root, identity) initialized = true } else { const manifest = await manifestAt(root, identity, policy.workspaceRoot) if (manifest.policyFingerprint !== policy.fingerprint) throw new Error('Sandbox Git policy changed; inspect git-state and explicitly git-reset before reusing history') await fs.mkdir(join(target, 'objects'), { recursive: true }); await fs.mkdir(join(target, 'refs'), { recursive: true }) const source = join(root, 'objects', identity.id, 'files'), checked = new Set([source]) for (const file of manifest.files) { assertActive(signal) const path = join(source, file.path); let parent = dirname(path) while (!checked.has(parent)) { if (!within(source, parent)) throw new Error('Sandbox Git path escaped state'); await directory(parent); checked.add(parent); parent = dirname(parent) } const bytes = await regular(path) if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error('Sandbox Git metadata digest mismatch') await fs.mkdir(dirname(join(target, file.path)), { recursive: true }) await fs.writeFile(join(target, file.path), bytes, { flag: 'wx', mode: 0o600 }) } } const before = identity, manifest = await manifestAt(root, before, policy.workspaceRoot) const mode = manifest.origin === 'host' ? 'host-mirror' : manifest.origin === 'remote' ? 'remote-clone' : manifest.origin === 'empty' ? 'new-repository' : 'private-snapshot' const state: SandboxGitState = { mode, baseline: ({ 'host-mirror': 'host-repository', 'remote-clone': 'remote-repository', 'new-repository': 'empty-repository', 'private-snapshot': 'filtered-workspace' } as const)[mode], initialized, revision: before.id, manifestSha256: before.manifestSha256, persistence: 'not-applied', ...(namespace ? { scope: ({ 'host-mirror': 'host-draft', 'remote-clone': 'clone-draft', 'new-repository': 'new-draft', 'private-snapshot': 'draft' } as const)[mode] } : {}) } const assertCurrent = async (): Promise => { assertActive(signal); await assertPolicyCurrent(policy) const current = await activeIdentity(root) if (current?.id !== before.id || current.manifestSha256 !== before.manifestSha256) throw new Error('Sandbox Git revision changed during execution or approval') } return { state, assertCurrent, close, async capture(signal) { const inventory = await scanRepository(target, signal) if (hash(JSON.stringify(inventory.files)) === manifest.contentHash) return null return storeRepository(root, target, policy, inventory, signal, manifest.origin) }, async publish(candidate) { await assertCurrent() await manifestAt(root, candidate, policy.workspaceRoot) await publishIdentity(root, candidate) state.revision = candidate.id; state.manifestSha256 = candidate.manifestSha256; state.persistence = 'saved' } } } catch (error) { await close(); throw error } } /** Trusted control-plane copy; validates every immutable source before publishing it. */ export async function copySandboxGitRepository(workspace: string, namespace: string, destination: string): Promise<{ identity: GitIdentity; manifest: GitManifest }> { const root = await prepareRoot(workspace, namespace), lockPath = join(root, 'repository.lock'), lock = await fs.open(lockPath, 'wx', 0o600) try { await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n') const identity = await activeIdentity(root) if (!identity) throw new Error('Sandbox Git has no saved repository') const manifest = await manifestAt(root, identity, workspace) await fs.mkdir(destination, { recursive: true }) await fs.mkdir(join(destination, 'objects'), { recursive: true }); await fs.mkdir(join(destination, 'refs'), { recursive: true }) for (const file of manifest.files) { const bytes = await regular(join(root, 'objects', identity.id, 'files', file.path)) if (bytes.length !== file.bytes || hash(bytes) !== file.sha256) throw new Error('Sandbox Git saved repository was changed') await fs.mkdir(dirname(join(destination, file.path)), { recursive: true }) await fs.writeFile(join(destination, file.path), bytes, { flag: 'wx', mode: 0o600 }) } return { identity, manifest } } finally { await lock.close(); await fs.unlink(lockPath) } } export async function replaceSandboxGitRepository(policy: SandboxEffectivePolicy, namespace: string, source: string, expected?: GitIdentity, origin?: 'host' | 'remote' | 'empty'): Promise { const root = await prepareRoot(policy.workspaceRoot, namespace), lockPath = join(root, 'repository.lock') if (origin && !await fs.lstat(join(root, 'host-sealed')).catch(() => null)) { if (await fs.lstat(lockPath).catch(() => null)) throw new Error('Sandbox Git is in use') await sealWorkspace(root) await fs.writeFile(join(root, 'host-sealed'), '1\n', { flag: 'wx' }) } const lock = await fs.open(lockPath, 'wx', 0o600) try { await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n') const current = await activeIdentity(root) if (expected && (current?.id !== expected.id || current.manifestSha256 !== expected.manifestSha256)) throw new Error('Sandbox Git revision changed during control-plane operation') const next = await storeRepository(root, source, policy, await scanRepository(source), undefined, origin) await assertPolicyCurrent(policy) await publishIdentity(root, next) return next } finally { await lock.close(); await fs.unlink(lockPath) } } export async function sandboxGitIdentity(workspace: string, namespace: string): Promise { return activeIdentity(await prepareRoot(workspace, namespace)) } export async function sandboxGitState(workspaceInput: string): Promise> { const workspace = await fs.realpath(workspaceInput), root = await prepareRoot(workspace), identity = await activeIdentity(root) if (!identity) return { mode: 'private-snapshot', state: 'missing' } const manifest = await manifestAt(root, identity, workspace) return { mode: 'private-snapshot', baseline: 'filtered-workspace', state: 'saved', ...identity, policyFingerprint: manifest.policyFingerprint, files: manifest.files.length, bytes: manifest.bytes, createdAt: manifest.createdAt } } /** Archive only the active reference; immutable revisions remain available for inspection. */ export async function resetSandboxGit(workspaceInput: string, namespace?: string): Promise { const workspace = await fs.realpath(workspaceInput), root = await prepareRoot(workspace, namespace), lockPath = join(root, 'repository.lock') const lock = await fs.open(lockPath, 'wx', 0o600) try { await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() }) + '\n') if (await activeIdentity(root)) await fs.rename(join(root, 'active.json'), join(root, `archived-${randomUUID()}.json`)) } finally { await lock.close(); await fs.unlink(lockPath) } } export async function recoverSandboxGitLock(workspace: string, namespace: string): Promise { const path = join(repositoryRoot(workspace, namespace), 'repository.lock') if (!await fs.lstat(path).catch(error => { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error })) return const bytes = await regular(path, 4096), value = JSON.parse(bytes.toString('utf8')) as { pid: number } if (!Number.isSafeInteger(value.pid) || value.pid < 1) throw new Error('Sandbox Git lock identity is invalid') try { process.kill(value.pid, 0); throw new Error('Sandbox Git lock still has a live owner') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error } if (!(await regular(path, 4096)).equals(bytes)) throw new Error('Sandbox Git lock changed during recovery') await fs.unlink(path) }