diff --git a/agents/contract.ts b/agents/contract.ts new file mode 100644 index 00000000..dbf67317 --- /dev/null +++ b/agents/contract.ts @@ -0,0 +1,82 @@ +/** Lane D/F boundary. Only the runner may construct requests after admission. */ +export interface TaskClone { + readonly id: string; + readonly taskId: string; + /** Staging clone, NOT a container-ready mount. D2 must allocate bounded storage. */ + readonly directory: string; + readonly head: string; +} + +export type Phase = 'planning' | 'questions' | 'review' | 'execute' | 'fix'; +export interface InvocationContext { + readonly snapshotId: string; + readonly planId: string; + readonly planRevision: number; + readonly assignmentId: string; + readonly referencedCodeHash: string; + readonly stateVersion: number; +} +export interface InvocationInput { + readonly clone: TaskClone; + readonly phase: Phase; + readonly vendor: 'claude' | 'codex'; + readonly approvedArgv: readonly (readonly string[])[]; + readonly deadline: number; + readonly attemptId: string; + readonly context: InvocationContext; +} +export type StopReason = 'cancelled' | 'timeout' | 'shutdown' | 'output-limit' | 'capture-failure'; +export interface InvocationResult { + readonly attemptId: string; + readonly context: InvocationContext; + readonly exitCode: number | null; + readonly signal: string | null; + readonly stopReason?: StopReason; + readonly stdout: string; + readonly stderr: string; +} +/** + * F owns persisted pending/stale and admission; D owns running invocations. + * cancel() records the first reason and requests termination, never settlement. + * settled resolves only after the container AND capture processes terminate. + * completed/failed/cancelled records are published by F using attemptId + context + * CAS; discarded stale output still must settle before releasing D's slot. + * Closing rejects admission before draining requests, cancelling, and awaiting + * settlement. No retry may replace an active invocation, even after lease expiry. + */ +export interface InvocationHandle { + readonly attemptId: string; + readonly settled: Promise; + cancel(reason: StopReason): void; +} + +const nonempty = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && !value.includes('\0'); +const integer = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; + +/** Capture a deep immutable request so caller edits cannot change an active run. */ +export function captureInvocation(input: InvocationInput, now = Date.now()): InvocationInput { + if (!input || !input.clone || !input.context) throw new Error('Missing invocation context.'); + if (!['planning', 'questions', 'review', 'execute', 'fix'].includes(input.phase) + || !['claude', 'codex'].includes(input.vendor)) throw new Error('Unsupported invocation profile.'); + if (!nonempty(input.attemptId) || !nonempty(input.clone.id) || !nonempty(input.clone.taskId) + || !nonempty(input.clone.directory) || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(input.clone.head)) + throw new Error('Invalid task clone or attempt identity.'); + if (!Number.isFinite(now) || !Number.isSafeInteger(input.deadline) || input.deadline <= now) + throw new Error('Invocation requires a finite future deadline.'); + const context = input.context; + if (![context.snapshotId, context.planId, context.assignmentId, context.referencedCodeHash].every(nonempty) + || !integer(context.planRevision) || !integer(context.stateVersion)) throw new Error('Invalid captured context.'); + if (!Array.isArray(input.approvedArgv) || Array.from(input.approvedArgv).some(argv => !Array.isArray(argv) + || argv.length === 0 || !nonempty(argv[0]) || Array.from(argv).some(arg => typeof arg !== 'string' || arg.includes('\0')))) + throw new Error('Commands must be complete literal argv arrays.'); + if (['planning', 'questions'].includes(input.phase) && input.approvedArgv.length) + throw new Error('Read-only authoring and questions cannot execute commands.'); + return Object.freeze({ ...input, clone: Object.freeze({ ...input.clone }), context: Object.freeze({ ...context }), + approvedArgv: Object.freeze(input.approvedArgv.map(argv => Object.freeze([...argv]))) }); +} + +/** Dispatcher predicate, not a sandbox. An adapter must enforce this externally. */ +export function permitsCommand(input: InvocationInput, argv: readonly string[]): boolean { + return !['planning', 'questions'].includes(input.phase) && input.approvedArgv.some(approved => + approved.length === argv.length && approved.every((arg, index) => arg === argv[index])); +} diff --git a/git/clone.ts b/git/clone.ts new file mode 100644 index 00000000..cc9a0caa --- /dev/null +++ b/git/clone.ts @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import type { TaskClone } from '../agents/contract.ts'; + +/** + * Prepare an independent committed snapshot. This is trusted staging, not the + * writable execution filesystem: D2 must reserve bounded storage and separate + * metadata before mounting it. Source must stay quiescent during this operation. + * No hooks, filters from user config, credentials, submodules or network access. + */ +export function createTaskClone(options: { + source: string; parent: string; taskId: string; head: string; timeoutMs?: number; +}): TaskClone { + if (!options.taskId || options.taskId.includes('\0')) throw new Error('Task identity is required.'); + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(options.head)) throw new Error('A full committed head is required.'); + const timeout = options.timeoutMs ?? 30_000; + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 120_000) throw new Error('Invalid clone deadline.'); + const deadline = performance.now() + timeout; + const remaining = () => { + const value = Math.ceil(deadline - performance.now()); + if (value <= 0) throw new Error('Clone deadline exceeded.'); + return value; + }; + const source = realpathSync(options.source), parent = realpathSync(options.parent); + const within = (base: string, path: string) => { + const rel = relative(base, path); + return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../')); + }; + if (within(source, parent)) throw new Error('Task storage must be outside the source repository.'); + // Deliberately do not inherit Git variables or credential/config environment. + const env = { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', GIT_GRAFT_FILE: '/dev/null' }; + const run = (cwd: string, ...args: string[]) => { + const result = execFileSync('git', [ + '--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=', + '-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args, + ], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'] }); + remaining(); + return result.toString().trim(); + }; + const common = realpathSync(resolve(source, run(source, 'rev-parse', '--git-common-dir'))); + if (within(common, parent)) throw new Error('Task storage must be outside source metadata.'); + function audit(metadata: string, independent: boolean) { + for (const name of ['shallow', 'info/grafts', 'objects/info/alternates', 'objects/info/http-alternates']) { + if (lstatSync(join(metadata, name), { throwIfNoEntry: false })) throw new Error(`Unsupported Git storage: ${name}`); + } + const pending = [join(metadata, 'objects')]; + let count = 0; + while (pending.length) { + remaining(); + if (++count > 100_000) throw new Error('Object storage exceeds inspection limit.'); + const path = pending.pop()!, stat = lstatSync(path); + if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error('Unsupported object entry.'); + if (independent && stat.isFile() && stat.nlink !== 1) throw new Error('Task objects must not be hard-linked.'); + if (stat.isDirectory()) { + const directory = opendirSync(path, { bufferSize: 1 }); + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + remaining(); + if (count + pending.length >= 100_000) throw new Error('Object storage exceeds inspection limit.'); + pending.push(join(path, entry.name)); + } + } finally { directory.closeSync(); } + } + } + } + audit(common, false); + if (run(source, 'for-each-ref', '--format=%(refname)', 'refs/replace')) throw new Error('Replacement objects are unsupported.'); + if (run(source, 'rev-parse', '--verify', `${options.head}^{commit}`) !== options.head) throw new Error('Head is not a commit.'); + const directory = mkdtempSync(join(parent, 'codeboost-task-')); + try { + run(parent, '-c', 'protocol.file.allow=always', 'clone', '--local', '--no-hardlinks', '--no-checkout', '--', source, directory); + const metadata = join(directory, '.git'); + if (!lstatSync(metadata).isDirectory()) throw new Error('Task requires standalone Git metadata.'); + audit(metadata, true); + run(directory, 'remote', 'remove', 'origin'); + run(directory, 'checkout', '--detach', options.head); + if (run(directory, 'rev-parse', 'HEAD') !== options.head) throw new Error('Task head changed during clone.'); + return Object.freeze({ id: randomUUID(), taskId: options.taskId, directory, head: options.head }); + } catch (error) { + rmSync(directory, { recursive: true, force: true }); + throw error; + } +} diff --git a/test/agent-clone.test.ts b/test/agent-clone.test.ts new file mode 100644 index 00000000..0a71a299 --- /dev/null +++ b/test/agent-clone.test.ts @@ -0,0 +1,131 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync, renameSync, opendirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createTaskClone } from '../git/clone.ts'; + +vi.mock('node:fs', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, readdirSync: vi.fn(actual.readdirSync), opendirSync: vi.fn(actual.opendirSync) }; +}); +vi.mock('node:child_process', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, execFileSync: vi.fn(actual.execFileSync) }; +}); + +const roots: string[] = []; +const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'clone-test-')); roots.push(root); + const source = join(root, 'source'), parent = join(root, 'tasks'); + mkdirSync(source); mkdirSync(parent); + git(source, 'init'); git(source, 'config', 'user.name', 'Test'); git(source, 'config', 'user.email', 'test@example.com'); + writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline'); + return { source, parent, head: git(source, 'rev-parse', 'HEAD'), taskId: 'task-1' }; +} +afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +describe('isolated staging clone', () => { + it('copies objects, ignores dirty source changes, and has no origin or shared metadata', () => { + const input = fixture(); + writeFileSync(join(input.source, 'file.txt'), 'uncommitted'); + const clone = createTaskClone(input); + expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n'); + expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true); + expect(git(clone.directory, 'status', '--porcelain')).toBe(''); + expect(git(clone.directory, 'remote')).toBe(''); + const hash = git(input.source, 'rev-parse', 'HEAD:file.txt'); + const path = join('objects', hash.slice(0, 2), hash.slice(2)); + const sourceObject = join(input.source, '.git', path), cloneObject = join(clone.directory, '.git', path); + const before = readFileSync(sourceObject); + expect(lstatSync(cloneObject).nlink).toBe(1); + expect(lstatSync(cloneObject).ino).not.toBe(lstatSync(sourceObject).ino); + writeFileSync(cloneObject, 'corrupted disposable task object'); + expect(readFileSync(sourceObject)).toEqual(before); + expect(git(input.source, 'cat-file', '-p', hash)).toBe('trusted'); + }); + it('supports linked-worktree sources but produces standalone metadata', () => { + const input = fixture(), linked = join(input.parent, 'linked'); + git(input.source, 'worktree', 'add', '--detach', linked, input.head); + const clone = createTaskClone({ ...input, source: linked }); + expect(lstatSync(join(clone.directory, '.git')).isDirectory()).toBe(true); + expect(git(clone.directory, 'rev-parse', 'HEAD')).toBe(input.head); + }); + it.each(['objects/info/alternates', 'objects/info/http-alternates', 'info/grafts', 'shallow'])('rejects %s before allocating a clone', name => { + const input = fixture(); + writeFileSync(join(input.source, '.git', name), ''); + expect(() => createTaskClone(input)).toThrow('Unsupported Git storage'); + expect(readdirSync(input.parent)).toEqual([]); + }); + it('rejects object symlinks and replacements', () => { + const input = fixture(); + symlinkSync('/tmp', join(input.source, '.git/objects/linked')); + expect(() => createTaskClone(input)).toThrow('object entry'); + rmSync(join(input.source, '.git/objects/linked')); + git(input.source, 'update-ref', `refs/replace/${input.head}`, input.head); + expect(() => createTaskClone(input)).toThrow('Replacement'); + }); + it('rejects nested storage, including paths through symlinks', () => { + const input = fixture(), nested = join(input.source, 'tasks'), alias = join(input.parent, 'alias'); + mkdirSync(nested); symlinkSync(nested, alias); + expect(() => createTaskClone({ ...input, parent: alias })).toThrow('outside'); + }); + it('requires a full existing commit and finite time budget', () => { + const input = fixture(); + expect(() => createTaskClone({ ...input, head: 'HEAD' })).toThrow('full committed'); + expect(() => createTaskClone({ ...input, head: 'f'.repeat(40) })).toThrow(); + expect(() => createTaskClone({ ...input, timeoutMs: Infinity })).toThrow('deadline'); + expect(readdirSync(input.parent)).toEqual([]); + }); + it('canonicalizes symlinked common metadata before checking storage containment', () => { + const input = fixture(), metadata = join(input.parent, 'metadata'); + renameSync(join(input.source, '.git'), metadata); + symlinkSync(metadata, join(input.source, '.git')); + const parent = join(metadata, 'tasks'); mkdirSync(parent); + // Disputed intermediate state: Git reports a lexical path through the link. + expect(git(input.source, 'rev-parse', '--git-common-dir')).toBe('.git'); + expect(lstatSync(join(input.source, '.git')).isSymbolicLink()).toBe(true); + expect(() => createTaskClone({ ...input, parent })).toThrow('outside source metadata'); + expect(readdirSync(parent)).toEqual([]); + }); + it('never materializes an entire object directory before checking its entry budget', () => { + const input = fixture(); + // A bulk enumeration is forbidden even for a small fixture; this asserts + // the disputed intermediate representation, not only a later limit error. + vi.mocked(readdirSync).mockClear(); + createTaskClone(input); + expect(readdirSync).not.toHaveBeenCalled(); + expect(opendirSync).toHaveBeenCalled(); + }); + it('rejects a successful final Git call that returns after the overall deadline', async () => { + const input = fixture(); + const actual = await vi.importActual('node:child_process'); + let elapsed = 0, lateResult = false; + vi.spyOn(performance, 'now').mockImplementation(() => elapsed); + vi.mocked(execFileSync).mockImplementation(((file: string, args: string[], options: object) => { + const result = actual.execFileSync(file, args, options); + if (args.at(-2) === 'rev-parse' && args.at(-1) === 'HEAD') { + elapsed = 1001; lateResult = true; + } + return result; + }) as typeof execFileSync); + expect(() => createTaskClone({ ...input, timeoutMs: 1000 })).toThrow('deadline'); + expect(lateResult).toBe(true); + expect(readdirSync(input.parent)).toEqual([]); + vi.mocked(execFileSync).mockImplementation(actual.execFileSync); + }); + it('does not inherit Git directory, index, configuration or object overrides', () => { + const input = fixture(); + const keys = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0']; + const old = keys.map(key => process.env[key]); + try { + Object.assign(process.env, { GIT_DIR: '/missing', GIT_INDEX_FILE: '/missing', GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.bare', GIT_CONFIG_VALUE_0: 'true' }); + const clone = createTaskClone(input); + expect(readFileSync(join(clone.directory, 'file.txt'), 'utf8')).toBe('trusted\n'); + } finally { + keys.forEach((key, i) => { if (old[i] === undefined) delete process.env[key]; else process.env[key] = old[i]; }); + } + }); +}); diff --git a/test/agent-contract.test.ts b/test/agent-contract.test.ts new file mode 100644 index 00000000..c4982319 --- /dev/null +++ b/test/agent-contract.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { captureInvocation, permitsCommand, type InvocationInput } from '../agents/contract.ts'; + +const request = (): InvocationInput => ({ + clone: { id: 'clone-1', taskId: 'task-1', directory: '/tasks/one', head: 'a'.repeat(40) }, + vendor: 'codex', phase: 'review', approvedArgv: [['npm', 'test']], deadline: 2000, attemptId: 'attempt-1', + context: { snapshotId: 'snapshot-1', planId: 'plan-1', planRevision: 1, assignmentId: 'assignment-1', + referencedCodeHash: 'hash-1', stateVersion: 3 }, +}); +describe('invocation boundary', () => { + it('captures identity, context and exact argv independently of mutable caller state', () => { + const original = request(); + const captured = captureInvocation(original, 1000); + (original.approvedArgv[0] as string[]).push('--changed'); + (original.context as { stateVersion: number }).stateVersion = 4; + expect(captured.context.stateVersion).toBe(3); + expect(captured.approvedArgv).toEqual([['npm', 'test']]); + expect(Object.isFrozen(captured.clone)).toBe(true); + expect(Object.isFrozen(captured.context)).toBe(true); + expect(Object.isFrozen(captured.approvedArgv[0])).toBe(true); + expect(permitsCommand(captured, ['npm', 'test'])).toBe(true); + expect(permitsCommand(captured, ['npm', 'test', '--changed'])).toBe(false); + expect(permitsCommand(captured, ['npm'])).toBe(false); + expect(permitsCommand(captured, ['sh', '-c', 'npm test'])).toBe(false); + }); + it.each(['planning', 'questions'] as const)('%s cannot acquire command permission', phase => { + expect(() => captureInvocation({ ...request(), phase }, 1000)).toThrow('cannot execute'); + const input = captureInvocation({ ...request(), phase, approvedArgv: [] }, 1000); + expect(permitsCommand(input, ['npm', 'test'])).toBe(false); + }); + it.each([NaN, Infinity, -1, 999, 1000, 1000.1])('rejects invalid deadline %s', deadline => { + expect(() => captureInvocation({ ...request(), deadline }, 1000)).toThrow('deadline'); + }); + it.each([[], [''], ['npm', '\0'], 'npm test'])('rejects malformed argv %j', argv => { + expect(() => captureInvocation({ ...request(), approvedArgv: [argv] } as InvocationInput, 1000)).toThrow('argv'); + }); + it('rejects missing context and unsupported profiles', () => { + expect(() => captureInvocation({ ...request(), context: { ...request().context, stateVersion: -1 } }, 1000)).toThrow('context'); + expect(() => captureInvocation({ ...request(), phase: 'shell' } as unknown as InvocationInput, 1000)).toThrow('profile'); + expect(() => captureInvocation({ ...request(), attemptId: '' }, 1000)).toThrow('identity'); + }); + it('rejects sparse allowlists with missing arguments or commands', () => { + const argv = ['npm', 'test']; delete argv[1]; + expect(1 in argv).toBe(false); + expect(() => captureInvocation({ ...request(), approvedArgv: [argv] }, 1000)).toThrow('argv'); + expect(() => captureInvocation({ ...request(), approvedArgv: new Array(1) }, 1000)).toThrow('argv'); + }); +});