From 6d9d199350b81524c6ec7faca96ac11df1701172 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:57:26 -0700 Subject: [PATCH] fix(miner): write acceptance criteria safely --- .../gittensory-miner/lib/coding-task-spec.js | 24 +++++++++++++++--- test/unit/miner-coding-task-spec.test.ts | 25 ++++++++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/gittensory-miner/lib/coding-task-spec.js b/packages/gittensory-miner/lib/coding-task-spec.js index ea77ee5bb..46e531508 100644 --- a/packages/gittensory-miner/lib/coding-task-spec.js +++ b/packages/gittensory-miner/lib/coding-task-spec.js @@ -1,5 +1,5 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { closeSync, constants as fsConstants, openSync, realpathSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, relative } from "node:path"; import { ACCEPTANCE_CRITERIA_FILENAME, buildAcceptanceCriteria, @@ -108,10 +108,26 @@ export function buildCodingTaskAcceptanceCriteria(issue, feasibility) { * @param {import("@jsonbored/gittensory-engine").AcceptanceCriteria} acceptanceCriteria * @returns {{ written: boolean, path: string | null }} */ +function assertContainedPath(root, path) { + const relativePath = relative(root, path); + if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) return; + throw new Error(`Refusing to write acceptance criteria outside the worktree: ${path}`); +} + export function writeAcceptanceCriteriaFile(workingDirectory, acceptanceCriteria) { if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) return { written: false, path: null }; - const path = join(workingDirectory, ACCEPTANCE_CRITERIA_FILENAME); - writeFileSync(path, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); + const root = realpathSync(workingDirectory); + const path = join(root, ACCEPTANCE_CRITERIA_FILENAME); + assertContainedPath(root, path); + + let fd; + try { + fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600); + writeFileSync(fd, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); + } finally { + if (fd !== undefined) closeSync(fd); + } + return { written: true, path }; } diff --git a/test/unit/miner-coding-task-spec.test.ts b/test/unit/miner-coding-task-spec.test.ts index d587f4aca..d7639cbd4 100644 --- a/test/unit/miner-coding-task-spec.test.ts +++ b/test/unit/miner-coding-task-spec.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -171,6 +171,29 @@ describe("writeAcceptanceCriteriaFile (#5132)", () => { const result = writeAcceptanceCriteriaFile(dir, doc); expect(result).toEqual({ written: false, path: null }); }); + + it("REGRESSION: refuses to follow a pre-existing acceptance-criteria symlink", () => { + const dir = tempDir(); + const outside = join(tempDir(), "victim-config.txt"); + writeFileSync(outside, "keep-me", "utf8"); + symlinkSync(outside, join(dir, "acceptance-criteria.json")); + const feasibility = buildCodingTaskFeasibility("acme/widgets", issue(), { issues: [issue()], pullRequests: [] }, claimLedger()); + const doc = buildCodingTaskAcceptanceCriteria(issue(), feasibility); + + expect(() => writeAcceptanceCriteriaFile(dir, doc)).toThrow(); + expect(readFileSync(outside, "utf8")).toBe("keep-me"); + }); + + it("refuses to overwrite a pre-existing acceptance-criteria file", () => { + const dir = tempDir(); + const path = join(dir, "acceptance-criteria.json"); + writeFileSync(path, "keep-me", "utf8"); + const feasibility = buildCodingTaskFeasibility("acme/widgets", issue(), { issues: [issue()], pullRequests: [] }, claimLedger()); + const doc = buildCodingTaskAcceptanceCriteria(issue(), feasibility); + + expect(() => writeAcceptanceCriteriaFile(dir, doc)).toThrow(); + expect(readFileSync(path, "utf8")).toBe("keep-me"); + }); }); describe("buildCodingTaskSpec (#5132)", () => {