Skip to content

Commit 876d142

Browse files
committed
Bound directory scanning and document Git selection policy
1 parent 8cf8320 commit 876d142

5 files changed

Lines changed: 48 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Inputs such as `planText` and the ledger must come from the caller. The future `
4444
## Current limits and safety
4545

4646
- History must be linear and descend from the requested base. Merge histories are rejected with a rebase instruction; repositories using object alternates or symlinked object storage are rejected (storage inspection is limited to 100,000 entries). Reads are bounded to 500 commits, 32 MiB per Git response, 64 MiB of unique blob bytes across the history (callers may lower `maxBlobBytes`), and a 2-second budget per line diff; oversized work fails explicitly.
47+
- The caller selects and trusts the repository and its Git administrative directory. Normal Git discovery, linked-worktree gitfiles, and symlinked gitdirs are supported; object-storage links and alternates inside that selected gitdir are rejected. This adapter is not a filesystem-containment boundary for untrusted repository roots.
4748
- Object storage must remain unchanged during a read; these library checks do not isolate a concurrently hostile filesystem.
4849
- Ownership uses line diffs, not semantic inference. Within one replacement block, new lines inherit all affected owners conservatively. Function context comes from Git hunk headers, not an AST.
4950
- The importer requires an accurate base-file list. It rejects path traversal, Git metadata paths, and traversal through a listed file/symlink/submodule. Runtime symlink and write-scope enforcement belong to the future container/runner; plan validation alone is not a sandbox.

‎docs/implementation/build-step-1.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ Fixed unbounded accumulation of unique blobs across a history. The adapter check
5555

5656
Fixed symlinked object storage bypassing the alternates check. Root, loose-directory, and pack-directory symlink regressions all reproduced the problem. The adapter now inspects the object store without following symlinks, rejects links at any depth, and bounds inspection to 100,000 entries before resolving commits. The caller must keep storage stable during reads; concurrent filesystem isolation belongs to the runner.
5757

58+
## Review round 7
59+
60+
Fixed eager directory listing before the inspection limit: use incremental directory reads with a one-entry buffer and close handles on every exit. Callers may lower the entry budget; the regression first failed and now rejects explicitly.
61+
62+
Declined the YAML finding: the pinned yaml 2.9.1 implementation and types explicitly define `maxAliasCount: 0` as rejecting all aliases (`-1` disables limits). An otherwise valid plan with an alias fails with “Alias resolution is disabled”.
63+
64+
Clarified the gitdir policy rather than rejecting normal linked worktrees. The caller selects and trusts the repository and its administrative directory; gitfiles and symlinked gitdirs are supported, while object-storage symlinks and alternates within that gitdir are rejected. Real-Git fixtures exercise both administrative layouts. Filesystem containment of an untrusted repository root belongs to the runner, not this read-only library.
65+
5866
## Remaining gates
5967

6068
This is a working foundation, not a completed application or a claim that all implementation tasks are done. T18's pure validation/edit core is present; its agent adapters, import UI, and persistence are pending. Ledger storage, rebase mappings, and the read-only review screen remain next. The already-fixed GitHub check belongs to the later GitHub/runner integration.

‎git/history.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { execFileSync } from 'node:child_process';
2-
import { lstatSync, readdirSync } from 'node:fs';
2+
import { lstatSync, opendirSync } from 'node:fs';
33
import { resolve as resolvePath, join } from 'node:path';
44
import type { FileDelta, FileVersion, History } from '../core/linking.ts';
55

66
/** Read-only Git adapter. Never follows working-tree symlinks or runs diff helpers. */
7-
export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', limits: { maxBlobBytes?: number } = {}): History {
7+
export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', limits: { maxBlobBytes?: number; maxObjectEntries?: number } = {}): History {
88
const maxBlobBytes = limits.maxBlobBytes ?? 64 * 1024 * 1024;
99
if (!Number.isSafeInteger(maxBlobBytes) || maxBlobBytes < 1 || maxBlobBytes > 64 * 1024 * 1024) throw new Error('Blob byte budget must be a positive integer no larger than 64 MiB.');
10+
const maxObjectEntries = limits.maxObjectEntries ?? 100_000;
11+
if (!Number.isSafeInteger(maxObjectEntries) || maxObjectEntries < 1 || maxObjectEntries > 100_000) throw new Error('Object entry budget must be a positive integer no larger than 100000.');
1012
let blobBytes = 0;
1113
// Inherited Git variables can redirect repository, index, config, and object lookup.
1214
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key)));
@@ -22,12 +24,17 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
2224
let inspected = 0;
2325
while (pending.length) {
2426
const path = pending.pop()!;
25-
if (++inspected > 100_000) throw new Error('Object storage inspection exceeds 100000 entries.');
27+
if (++inspected > maxObjectEntries) throw new Error('Object storage inspection exceeds its entry budget.');
2628
const stat = lstatSync(path);
2729
if (stat.isSymbolicLink()) throw new Error('Review repositories must not use symlinked object storage.');
28-
if (stat.isDirectory()) for (const entry of readdirSync(path)) {
29-
if (pending.length + inspected >= 100_000) throw new Error('Object storage inspection exceeds 100000 entries.');
30-
pending.push(join(path, entry));
30+
if (stat.isDirectory()) {
31+
const directory = opendirSync(path, { bufferSize: 1 });
32+
try {
33+
for (let entry = directory.readSync(); entry; entry = directory.readSync()) {
34+
if (pending.length + inspected >= maxObjectEntries) throw new Error('Object storage inspection exceeds its entry budget.');
35+
pending.push(join(path, entry.name));
36+
}
37+
} finally { directory.closeSync(); }
3138
}
3239
}
3340
const alternates = join(objects, 'info/alternates');

‎test/history.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,20 @@ it.each(['root', 'loose', 'pack'])('rejects symlinked %s object storage', kind =
208208
renameSync(storage, borrowed); symlinkSync(borrowed, storage);
209209
expect(() => readHistory(f.dir, f.base)).toThrow(/symlinked object storage/i);
210210
});
211+
212+
it('enforces a caller-lowered object inspection entry limit', () => {
213+
const f = fixture();
214+
expect(() => readHistory(f.dir, f.base, 'HEAD', { maxObjectEntries: 1 })).toThrow(/inspection exceeds/i);
215+
});
216+
it('supports a caller-selected linked worktree gitdir', () => {
217+
const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1');
218+
const worktree = mkdtempSync(join(tmpdir(), 'codeboost-linked-')); dirs.push(worktree);
219+
f.git('worktree', 'add', '--detach', worktree, 'HEAD');
220+
expect(readHistory(worktree, f.base).final[0]!.after!.text).toBe('changed\n');
221+
});
222+
it('supports a caller-selected symlinked gitdir with regular object storage', () => {
223+
const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1');
224+
const metadata = mkdtempSync(join(tmpdir(), 'codeboost-gitdir-')); dirs.push(metadata);
225+
rmSync(metadata, { recursive: true }); renameSync(join(f.dir, '.git'), metadata); symlinkSync(metadata, join(f.dir, '.git'));
226+
expect(readHistory(f.dir, f.base).final[0]!.after!.text).toBe('changed\n');
227+
});

‎test/plan.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { stringify } from 'yaml';
12
import { readFileSync } from 'node:fs';
23
import { describe, expect, it } from 'vitest';
34
import { applySuggestion, assertEditReply, commandArgv, commandAllowed, importPlan, isRepoPath, validatePlan, type Plan, type PlanContext } from '../core/plan.ts';
@@ -106,3 +107,11 @@ it('rejects file/parent collisions declared within the same item', () => {
106107
const plan = basePlan(); plan.items[0]!.files = ['new', 'new/child'].map(path => ({ path, kind: 'add', renamed_from: null, change: 'Create' }));
107108
expect(validatePlan(plan, context).errors.some(e => e.code === 'path-parent')).toBe(true);
108109
});
110+
111+
it('rejects an alias in an otherwise valid plan before schema validation', () => {
112+
const source = stringify(basePlan());
113+
expect(() => importPlan(source, 'yaml', context, 1)).not.toThrow();
114+
const aliased = source.replace('summary: Change behavior.', 'summary: &summary Change behavior.').replace('title: Change', 'title: *summary');
115+
expect(aliased).toContain('*summary');
116+
expect(() => importPlan(aliased, 'yaml', context, 1)).toThrow(/Alias resolution is disabled/);
117+
});

0 commit comments

Comments
 (0)