Skip to content

Commit 47e7bd5

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/lane-g1-planning-screen
2 parents 1ad5aaf + d77eabc commit 47e7bd5

2 files changed

Lines changed: 36 additions & 11 deletions

File tree

‎git/clone.ts‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execFileSync } from 'node:child_process';
22
import { randomUUID } from 'node:crypto';
33
import { lstatSync, mkdtempSync, opendirSync, realpathSync, rmSync } from 'node:fs';
4-
import { isAbsolute, join, relative, resolve } from 'node:path';
4+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
55
import type { TaskClone } from '../agents/contract.ts';
66
import { runInProcessGroup, type ProcessGroup } from '../agents/process-group.ts';
77
import { runTrackedProcess, type ProcessGroupLifecycle } from '../agents/tracked-docker.ts';
@@ -46,6 +46,19 @@ export interface CloneOptions {
4646
readonly timeoutMs?: number;
4747
}
4848

49+
interface PathSemantics {
50+
readonly sep: string;
51+
isAbsolute(path: string): boolean;
52+
relative(from: string, to: string): string;
53+
}
54+
55+
/** Whether `candidate` is `base` itself or one of its descendants under the supplied platform's path rules. */
56+
export function pathIsWithin(base: string, candidate: string,
57+
paths: PathSemantics = { sep, isAbsolute, relative }): boolean {
58+
const rel = paths.relative(base, candidate);
59+
return rel === '' || (!paths.isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${paths.sep}`));
60+
}
61+
4962
/**
5063
* The clone, as a sequence of Git calls. Both variants run these same steps; only how each call runs differs. Every
5164
* failure after the clone directory exists, an abort included, removes it before the error leaves.
@@ -62,19 +75,15 @@ function* cloneSteps(options: CloneOptions): Generator<CloneStep, TaskClone, Git
6275
return value;
6376
};
6477
const source = realpathSync(options.source), parent = realpathSync(options.parent);
65-
const within = (base: string, path: string) => {
66-
const rel = relative(base, path);
67-
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith('../'));
68-
};
69-
if (within(source, parent)) throw new Error('Task storage must be outside the source repository.');
78+
if (pathIsWithin(source, parent)) throw new Error('Task storage must be outside the source repository.');
7079
function* run(cwd: string, ...args: string[]): Generator<CloneStep, string, GitOutcome | undefined> {
7180
const outcome = yield { cwd, args, timeoutMs: remaining() };
7281
if (!outcome || outcome.status !== 0) throw outcome?.error ?? new Error(`git ${args[0] ?? ''} failed.`);
7382
remaining();
7483
return outcome.stdout.trim();
7584
}
7685
const common = realpathSync(resolve(source, yield* run(source, 'rev-parse', '--git-common-dir')));
77-
if (within(common, parent)) throw new Error('Task storage must be outside source metadata.');
86+
if (pathIsWithin(common, parent)) throw new Error('Task storage must be outside source metadata.');
7887
function* audit(metadata: string, independent: boolean): Generator<CloneStep, void, GitOutcome | undefined> {
7988
for (const name of ['shallow', 'info/grafts', 'objects/info/alternates', 'objects/info/http-alternates']) {
8089
if (lstatSync(join(metadata, name), { throwIfNoEntry: false })) throw new Error(`Unsupported Git storage: ${name}`);

‎test/agent-clone.test.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { execFileSync } from 'node:child_process';
22
import { fixtureGit } from './fixtures/git.ts';
33
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, lstatSync, symlinkSync, writeFileSync, renameSync, opendirSync } from 'node:fs';
44
import { tmpdir } from 'node:os';
5-
import { join } from 'node:path';
5+
import { join, win32 } from 'node:path';
66
import { afterEach, describe, expect, it, vi } from 'vitest';
7-
import { createTaskClone } from '../git/clone.ts';
7+
import { createTaskClone, pathIsWithin } from '../git/clone.ts';
88

99
vi.mock('node:fs', async importOriginal => {
1010
const actual = await importOriginal<typeof import('node:fs')>();
@@ -26,6 +26,22 @@ function fixture() {
2626
return { source, parent, head: git(source, 'rev-parse', 'HEAD'), taskId: 'task-1' };
2727
}
2828
afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
29+
describe('path containment', () => {
30+
it.each([
31+
['same directory despite case', 'C:\\repo', 'c:\\REPO', true],
32+
['nested directory', 'C:\\repo', 'C:\\repo\\tasks', true],
33+
['similarly named sibling', 'C:\\repo', 'C:\\repository', false],
34+
['same-drive sibling', 'C:\\repo', 'C:\\tasks', false],
35+
['parent directory', 'C:\\repo\\source', 'C:\\repo', false],
36+
['different drive', 'C:\\repo', 'D:\\tasks', false],
37+
['nested UNC directory', '\\\\server\\share\\repo', '\\\\server\\share\\repo\\tasks', true],
38+
['same-share UNC sibling', '\\\\server\\share\\repo', '\\\\server\\share\\tasks', false],
39+
['different UNC share', '\\\\server\\share\\repo', '\\\\server\\other\\tasks', false],
40+
['different UNC server', '\\\\server\\share\\repo', '\\\\other\\share\\tasks', false],
41+
])('uses Windows boundaries for a %s', (_name, base, candidate, expected) => {
42+
expect(pathIsWithin(base, candidate, win32)).toBe(expected);
43+
});
44+
});
2945
describe('isolated staging clone', () => {
3046
it('copies objects, ignores dirty source changes, and has no origin or shared metadata', () => {
3147
const input = fixture();
@@ -66,9 +82,9 @@ describe('isolated staging clone', () => {
6682
git(input.source, 'update-ref', `refs/replace/${input.head}`, input.head);
6783
expect(() => createTaskClone(input)).toThrow('Replacement');
6884
});
69-
it('rejects nested storage, including paths through symlinks', () => {
85+
it('rejects nested storage after resolving a directory alias, including a Windows junction', () => {
7086
const input = fixture(), nested = join(input.source, 'tasks'), alias = join(input.parent, 'alias');
71-
mkdirSync(nested); symlinkSync(nested, alias);
87+
mkdirSync(nested); symlinkSync(nested, alias, process.platform === 'win32' ? 'junction' : 'dir');
7288
expect(() => createTaskClone({ ...input, parent: alias })).toThrow('outside');
7389
});
7490
it('requires a full existing commit and finite time budget', () => {

0 commit comments

Comments
 (0)