Skip to content

Commit 611aedd

Browse files
committed
Reject Git graft and shallow ancestry metadata
1 parent 38d36b8 commit 611aedd

4 files changed

Lines changed: 28 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ Inputs such as `planText` and the ledger must come from the caller. The future `
4545

4646
## Current limits and safety
4747

48-
- 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`), 8 MiB of cumulative diff output, 20,000 cumulative file records (including the final diff), and a shared 30-second monotonic deadline for a read. Git children receive only the remaining time and are killed on timeout. Filesystem inspections check the deadline between operations; a blocked filesystem syscall still requires an external worker supervisor. Oversized work fails explicitly.
48+
- History must be linear and descend from the requested base. Merge histories are rejected with a rebase instruction; repositories using grafts, shallow ancestry, 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`), 8 MiB of cumulative diff output, 20,000 cumulative file records (including the final diff), and a shared 30-second monotonic deadline for a read. Git children receive only the remaining time and are killed on timeout. Filesystem inspections check the deadline between operations; a blocked filesystem syscall still requires an external worker supervisor. Oversized work fails explicitly.
4949
- Linking separately bounds cumulative split lines and candidate segments to 100,000 each, reference/work operations to 1,000,000, and text/origin strings to 32 Mi UTF-16 code units. It checks before expanding lines/origin sets; grouping no longer repeatedly splits accumulated content. Callers may lower these budgets. Linking has a 30-second overall deadline and each line diff uses at most 2 seconds or the remaining total, whichever is smaller.
5050
- 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.
51-
- Object storage must remain unchanged during a read; these library checks do not isolate a concurrently hostile filesystem.
51+
- Git administrative metadata and object storage must remain unchanged during a read; these library checks do not isolate a concurrently hostile filesystem.
5252
- 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.
5353
- The importer requires accurate typed base entries, stable plan identity, a selected issue, and a trusted checkout path-identity function. 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.
5454
- Allowed commands restrict accidents, not hostile programs or changed scripts. Parsing returns argv and never executes it. An unlisted valid command is a warning and must not run until allowed.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,7 @@ File metadata retains the whole rename path lineage, so a later deletion include
104104
## Merged-contract review round 4
105105

106106
The review had no inline findings, but its summary identified lost metadata after deleting and recreating a path. A real binary-file regression reproduced only the recreating owner being retained. Metadata updates now combine existing path evidence before storing/propagating it, so prior deletion owners survive reuse and subsequent renames. Owned deletion plus recreation is Ambiguous; foreign deletion plus recreation stays Unplanned. Both regressions pass.
107+
108+
## Merged-contract review round 5
109+
110+
Reproduced a repository-local graft making an unrelated root commit appear descended from the selected base. The adapter now rejects Git-resolved graft and shallow metadata before object/ancestry reads and pins the child graft file to `/dev/null` as defense in depth. Git resolves administrative paths so linked worktrees share the same check. Caller isolation must keep all Git metadata, not just blobs, stable during a read. Full shallow-clone support is deliberately outside this linear immutable-history slice.

‎git/history.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
3636
result = execFileSync('git', ['--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.allow=never', ...args], {
3737
cwd: repo, maxBuffer: 32 * 1024 * 1024, timeout, killSignal: 'SIGKILL',
3838
env: { ...environment, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0',
39-
GIT_NO_LAZY_FETCH: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' },
39+
GIT_NO_LAZY_FETCH: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_GRAFT_FILE: '/dev/null' },
4040
stdio: ['ignore', 'pipe', 'pipe'],
4141
});
4242
} catch (error) {
@@ -46,6 +46,14 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
4646
remaining();
4747
return result;
4848
};
49+
// Parent-rewriting files are not immutable commit ancestry. Resolve via Git so
50+
// linked worktrees use the common administrative directory as well.
51+
const commonDirectory = resolvePath(repo, run('rev-parse', '--git-common-dir').toString().trim());
52+
for (const name of ['info/grafts', 'shallow']) {
53+
const path = join(commonDirectory, name);
54+
if (lstatSync(path, { throwIfNoEntry: false }))
55+
throw new Error(`Review repositories must not use graft or shallow ancestry metadata (${name}).`);
56+
}
4957
// Inspect storage without following links before any object-resolving command.
5058
const objects = resolvePath(repo, run('rev-parse', '--git-path', 'objects').toString().trim());
5159
const pending = [objects];

‎test/history.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,16 @@ it.each(['P1', undefined])('retains deletion and recreation owners on reused-pat
315315
expect(card.owners).toEqual([owner ?? null, 'P2']);
316316
expect(card.row).toBe(owner ? 'Ambiguous' : 'Unplanned');
317317
});
318+
319+
it('rejects local grafts that make an unrelated commit appear descended from the base', () => {
320+
const f = fixture(); f.git('checkout', '--orphan', 'unrelated'); f.git('rm', '-rf', '.');
321+
f.write('a.txt', 'unrelated history\n'); const head = f.commit('P1');
322+
f.write('.git/info/grafts', `${head} ${f.base}\n`);
323+
expect(() => readHistory(f.dir, f.base, head)).toThrow(/graft/i);
324+
});
325+
326+
it('rejects shallow parent rewriting before loading history', () => {
327+
const f = fixture(); f.write('a.txt', 'changed\n'); const head = f.commit('P1');
328+
f.write('.git/shallow', `${head}\n`);
329+
expect(() => readHistory(f.dir, f.base, head)).toThrow(/shallow/i);
330+
});

0 commit comments

Comments
 (0)