fix(merger): restrict staging to allowlist and harden git invocations
Replaces blanket `git add -A` in `commitOrAmendMergeWithFixes` with an explicit allowlist of (squash-staged ∪ fix-agent-modified) paths, so unrelated dirty files in the project root no longer get swept into a task's squash commit. The in-merge fix agent now snapshots the working tree before/after its session to capture exactly which files it touched. Hardens the git invocations the allowlist relies on: - All `git add` and `git checkout --ours/--theirs` calls switched from shell-interpolated `execAsync` to `execFile` array form, eliminating path-injection surface and batching per-file spawns into one call. - `snapshotDirtyFiles` adopts `git -z` NUL-delimited parsing so paths with embedded spaces or specials are handled correctly. - Long allowlist debug logs are truncated to 20 entries with an overflow marker. Refused-to-stage paths emit a warn naming each file so the user can audit what was filtered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,35 @@ vi.mock("node:child_process", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// execFile(file, args, opts, cb) — assemble a command string and delegate to
|
||||
// execSyncFn so the same mock infrastructure covers execFile-based git calls.
|
||||
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
|
||||
const callback = typeof opts === "function" ? opts : cb;
|
||||
const options = typeof opts === "function" ? undefined : opts;
|
||||
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
|
||||
try {
|
||||
const out = execSyncFn(cmd, options);
|
||||
const stdout = out === undefined ? "" : out.toString();
|
||||
if (typeof callback === "function") callback(null, stdout, "");
|
||||
} catch (err: any) {
|
||||
if (typeof callback === "function") {
|
||||
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
|
||||
}
|
||||
}
|
||||
});
|
||||
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
|
||||
if (err) {
|
||||
err.stdout = stdout;
|
||||
err.stderr = stderr;
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// spawn() is used by the merger's verification runner. Route it through the
|
||||
// same execSyncFn mock so a single mockedExecSync.mockImplementation controls
|
||||
// both git calls (execSync) and verification commands (spawn). Throwing from
|
||||
@@ -104,7 +133,7 @@ vi.mock("node:child_process", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn };
|
||||
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
|
||||
});
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
|
||||
Reference in New Issue
Block a user