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:
gsxdsm
2026-05-04 13:17:29 -07:00
parent 840dad771d
commit 89da1de311
5 changed files with 837 additions and 20 deletions

View File

@@ -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),