Skip to content

Commit d110d81

Browse files
committed
Harden clone containment traversal and deadline validation
1 parent 489066e commit d110d81

4 files changed

Lines changed: 76 additions & 15 deletions

File tree

‎agents/contract.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ export function captureInvocation(input: InvocationInput, now = Date.now()): Inv
6666
const context = input.context;
6767
if (![context.snapshotId, context.planId, context.assignmentId, context.referencedCodeHash].every(nonempty)
6868
|| !integer(context.planRevision) || !integer(context.stateVersion)) throw new Error('Invalid captured context.');
69-
if (!Array.isArray(input.approvedArgv) || input.approvedArgv.some(argv => !Array.isArray(argv)
70-
|| argv.length === 0 || !nonempty(argv[0]) || argv.some(arg => typeof arg !== 'string' || arg.includes('\0'))))
69+
if (!Array.isArray(input.approvedArgv) || Array.from(input.approvedArgv).some(argv => !Array.isArray(argv)
70+
|| argv.length === 0 || !nonempty(argv[0]) || Array.from(argv).some(arg => typeof arg !== 'string' || arg.includes('\0'))))
7171
throw new Error('Commands must be complete literal argv arrays.');
7272
if (['planning', 'questions'].includes(input.phase) && input.approvedArgv.length)
7373
throw new Error('Read-only authoring and questions cannot execute commands.');

‎git/clone.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { execFileSync } from 'node:child_process';
22
import { randomUUID } from 'node:crypto';
3-
import { lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs';
3+
import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node:fs';
44
import { isAbsolute, join, relative, resolve } from 'node:path';
55
import type { TaskClone } from '../agents/contract.ts';
66

@@ -32,12 +32,16 @@ export function createTaskClone(options: {
3232
// Deliberately do not inherit Git variables or credential/config environment.
3333
const env = { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
3434
GIT_TERMINAL_PROMPT: '0', GIT_NO_LAZY_FETCH: '1', GIT_GRAFT_FILE: '/dev/null' };
35-
const run = (cwd: string, ...args: string[]) => execFileSync('git', [
36-
'--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=',
37-
'-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args,
38-
], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024,
39-
stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
40-
const common = resolve(source, run(source, 'rev-parse', '--git-common-dir'));
35+
const run = (cwd: string, ...args: string[]) => {
36+
const result = execFileSync('git', [
37+
'--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'init.templateDir=',
38+
'-c', 'protocol.allow=never', '-c', 'submodule.recurse=false', ...args,
39+
], { cwd, env, timeout: remaining(), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024,
40+
stdio: ['ignore', 'pipe', 'pipe'] });
41+
remaining();
42+
return result.toString().trim();
43+
};
44+
const common = realpathSync(resolve(source, run(source, 'rev-parse', '--git-common-dir')));
4145
if (within(common, parent)) throw new Error('Task storage must be outside source metadata.');
4246
function audit(metadata: string, independent: boolean) {
4347
for (const name of ['shallow', 'info/grafts', 'objects/info/alternates', 'objects/info/http-alternates']) {
@@ -52,9 +56,14 @@ export function createTaskClone(options: {
5256
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error('Unsupported object entry.');
5357
if (independent && stat.isFile() && stat.nlink !== 1) throw new Error('Task objects must not be hard-linked.');
5458
if (stat.isDirectory()) {
55-
const children = readdirSync(path);
56-
if (count + pending.length + children.length > 100_000) throw new Error('Object storage exceeds inspection limit.');
57-
pending.push(...children.map(child => join(path, child)));
59+
const directory = opendirSync(path, { bufferSize: 1 });
60+
try {
61+
for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
62+
remaining();
63+
if (count + pending.length >= 100_000) throw new Error('Object storage exceeds inspection limit.');
64+
pending.push(join(path, entry.name));
65+
}
66+
} finally { directory.closeSync(); }
5867
}
5968
}
6069
}

‎test/agent-clone.test.ts‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { execFileSync } from 'node:child_process';
2-
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync } from 'node:fs';
2+
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync, renameSync, opendirSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
5-
import { afterEach, describe, expect, it } from 'vitest';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
66
import { createTaskClone } from '../git/clone.ts';
77

8+
vi.mock('node:fs', async importOriginal => {
9+
const actual = await importOriginal<typeof import('node:fs')>();
10+
return { ...actual, readdirSync: vi.fn(actual.readdirSync), opendirSync: vi.fn(actual.opendirSync) };
11+
});
12+
vi.mock('node:child_process', async importOriginal => {
13+
const actual = await importOriginal<typeof import('node:child_process')>();
14+
return { ...actual, execFileSync: vi.fn(actual.execFileSync) };
15+
});
16+
817
const roots: string[] = [];
918
const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args],
1019
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -16,7 +25,7 @@ function fixture() {
1625
writeFileSync(join(source, 'file.txt'), 'trusted\n'); git(source, 'add', '.'); git(source, 'commit', '-m', 'baseline');
1726
return { source, parent, head: git(source, 'rev-parse', 'HEAD'), taskId: 'task-1' };
1827
}
19-
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
28+
afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
2029
describe('isolated staging clone', () => {
2130
it('copies objects, ignores dirty source changes, and has no origin or shared metadata', () => {
2231
const input = fixture();
@@ -69,6 +78,43 @@ describe('isolated staging clone', () => {
6978
expect(() => createTaskClone({ ...input, timeoutMs: Infinity })).toThrow('deadline');
7079
expect(readdirSync(input.parent)).toEqual([]);
7180
});
81+
it('canonicalizes symlinked common metadata before checking storage containment', () => {
82+
const input = fixture(), metadata = join(input.parent, 'metadata');
83+
renameSync(join(input.source, '.git'), metadata);
84+
symlinkSync(metadata, join(input.source, '.git'));
85+
const parent = join(metadata, 'tasks'); mkdirSync(parent);
86+
// Disputed intermediate state: Git reports a lexical path through the link.
87+
expect(git(input.source, 'rev-parse', '--git-common-dir')).toBe('.git');
88+
expect(lstatSync(join(input.source, '.git')).isSymbolicLink()).toBe(true);
89+
expect(() => createTaskClone({ ...input, parent })).toThrow('outside source metadata');
90+
expect(readdirSync(parent)).toEqual([]);
91+
});
92+
it('never materializes an entire object directory before checking its entry budget', () => {
93+
const input = fixture();
94+
// A bulk enumeration is forbidden even for a small fixture; this asserts
95+
// the disputed intermediate representation, not only a later limit error.
96+
vi.mocked(readdirSync).mockClear();
97+
createTaskClone(input);
98+
expect(readdirSync).not.toHaveBeenCalled();
99+
expect(opendirSync).toHaveBeenCalled();
100+
});
101+
it('rejects a successful final Git call that returns after the overall deadline', async () => {
102+
const input = fixture();
103+
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
104+
let elapsed = 0, lateResult = false;
105+
vi.spyOn(performance, 'now').mockImplementation(() => elapsed);
106+
vi.mocked(execFileSync).mockImplementation(((file: string, args: string[], options: object) => {
107+
const result = actual.execFileSync(file, args, options);
108+
if (args.at(-2) === 'rev-parse' && args.at(-1) === 'HEAD') {
109+
elapsed = 1001; lateResult = true;
110+
}
111+
return result;
112+
}) as typeof execFileSync);
113+
expect(() => createTaskClone({ ...input, timeoutMs: 1000 })).toThrow('deadline');
114+
expect(lateResult).toBe(true);
115+
expect(readdirSync(input.parent)).toEqual([]);
116+
vi.mocked(execFileSync).mockImplementation(actual.execFileSync);
117+
});
72118
it('does not inherit Git directory, index, configuration or object overrides', () => {
73119
const input = fixture();
74120
const keys = ['GIT_DIR', 'GIT_INDEX_FILE', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'];

‎test/agent-contract.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,10 @@ describe('invocation boundary', () => {
3939
expect(() => captureInvocation({ ...request(), phase: 'shell' } as unknown as InvocationInput, 1000)).toThrow('profile');
4040
expect(() => captureInvocation({ ...request(), attemptId: '' }, 1000)).toThrow('identity');
4141
});
42+
it('rejects sparse allowlists with missing arguments or commands', () => {
43+
const argv = ['npm', 'test']; delete argv[1];
44+
expect(1 in argv).toBe(false);
45+
expect(() => captureInvocation({ ...request(), approvedArgv: [argv] }, 1000)).toThrow('argv');
46+
expect(() => captureInvocation({ ...request(), approvedArgv: new Array(1) }, 1000)).toThrow('argv');
47+
});
4248
});

0 commit comments

Comments
 (0)