diff --git a/backend/scripts/p1-acceptance.mjs b/backend/scripts/p1-acceptance.mjs index f086f89e..1752504a 100755 --- a/backend/scripts/p1-acceptance.mjs +++ b/backend/scripts/p1-acceptance.mjs @@ -555,11 +555,15 @@ function rawGitConfigEntries(path, required = false) { if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("unsafe Git repository state: local config is unsafe"); return parseLocalGitConfig(readFileSync(path, "utf8")); } -function unsafeRawGitConfigKey(key) { +function unsafeRawGitConfigKey(key, value = "") { const normalized = key.toLowerCase(); - return /^filter\..+\.(?:clean|smudge|process|required)$/.test(normalized) - || /^remote\..+\.(?:uploadpack|receivepack)$/.test(normalized) - || /^(?:core\.(?:hookspath|attributesfile)|diff\.external|interactive\.difffilter)$/.test(normalized) + if (normalized === "core.fsmonitor" && /^(?:true|false|1|0)$/i.test(String(value).trim())) return false; + return /^(?:filter|diff)\..+\.(?:clean|smudge|process|required|textconv|external|command)$/.test(normalized) + || /^(?:remote\..+\.(?:uploadpack|receivepack)|uploadpack\..+|receivepack\..+)$/.test(normalized) + || /^credential(?:\..+)?\.helper$/.test(normalized) + || /^(?:core\.(?:hookspath|attributesfile|fsmonitor|sshcommand|askpass|pager|editor|sequence\.editor)|sequence\.editor|interactive\.difffilter|diff\.external|gpg\.program|gpg\.ssh\.program)$/.test(normalized) + || /^merge\..+\.driver$/.test(normalized) + || /^(?:pager\..+|alias\..+|difftool\..+\.(?:cmd|path)|mergetool\..+\.(?:cmd|path))$/.test(normalized) || /^include(?:if\..+)?\.path$/.test(normalized); } function validateRawGitDirectoryState(gitDirectory) { @@ -568,7 +572,7 @@ function validateRawGitDirectoryState(gitDirectory) { ...rawGitConfigEntries(join(common, "config"), true), ...rawGitConfigEntries(join(gitDirectory, "config.worktree")), ]; - if (entries.some(([key]) => unsafeRawGitConfigKey(key))) { + if (entries.some(([key, value]) => unsafeRawGitConfigKey(key, value))) { throw new Error("unsafe Git repository state: executable local config is present"); } for (const hooks of new Set([join(common, "hooks"), join(gitDirectory, "hooks")])) { diff --git a/backend/scripts/p1-acceptance.test.mjs b/backend/scripts/p1-acceptance.test.mjs index 8ee57ecb..95200605 100644 --- a/backend/scripts/p1-acceptance.test.mjs +++ b/backend/scripts/p1-acceptance.test.mjs @@ -317,6 +317,37 @@ test("raw runCommand rejects a configured clean filter before exact Git add", as await assert.rejects(lstat(marker)); }); +test("raw runCommand rejects a diff driver textconv before exact Git show", async () => { + const repositoryRoot = await fakeRepository(); + const marker = join(repositoryRoot, "textconv-helper-ran"); + const helper = join(repositoryRoot, "textconv-helper"); + await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Fixture Curator"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-curator@example.invalid"], { cwd: repositoryRoot }); + await writeFile(join(repositoryRoot, ".gitattributes"), "file diff=evil\n"); + await execFileAsync("/usr/bin/git", ["add", ".gitattributes"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["commit", "-m", "attributes"], { cwd: repositoryRoot }); + await writeFile(join(repositoryRoot, "file"), "v1\n"); + await execFileAsync("/usr/bin/git", ["add", "file"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["commit", "-m", "v1"], { cwd: repositoryRoot }); + await writeFile(join(repositoryRoot, "file"), "v2\n"); + await execFileAsync("/usr/bin/git", ["add", "file"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["commit", "-m", "v2"], { cwd: repositoryRoot }); + await writeFile(helper, `#!/bin/sh\nprintf ran > '${marker}'\nexit 0\n`, { mode: 0o700 }); + await execFileAsync("/usr/bin/git", ["config", "diff.evil.textconv", `'${helper}'`], { cwd: repositoryRoot }); + const emptyHooks = join(repositoryRoot, "registry", "locks", "empty-hooks"); + await mkdir(emptyHooks, { recursive: true }); + + const { gitPath } = await resolveProductionExecutables({ + repositoryRoot: await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")), + }); + await assert.rejects( + runCommand({ executable: gitPath, argv: ["-c", `core.hooksPath=${emptyHooks}`, "show", "HEAD"], cwd: repositoryRoot, env: process.env }), + /unsafe Git repository state/, + ); + await assert.rejects(lstat(marker)); +}); + test("safe environment rejects ambient THT and keeps only strict process allowlist plus fixture values", () => { const safe = buildSafeEnvironment({