diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a217758..d77563a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed. - production runtime credential envelope parsing을 fail-closed로 강화한다. GitHub App PKCS#1 key의 canonical PKCS#8 변환은 유지하되, bare carriage return처럼 비정규 body bytes가 포함된 PKCS#8 PEM은 readiness/import 단계의 암묵적 정규화에 넘기지 않고 즉시 거부해 malformed secret이 ready 상태로 승인되지 않게 한다. - Governance and Maintainer App GitHub CLI subprocesses now keep CLI config and XDG state inside the validated capability file's private parent directory, preventing a missing ambient home from writing `.local` state into the checkout. - Maintainer App readiness now requires the retained governance audit's `protected_main_sha` to equal the freshly collected default-branch head, preventing evidence from different protected-main revisions from being combined into one passing report. The governance collector authenticates every tracked checkout byte against exact HEAD before and after live collection, so modified audit source cannot emit PASS evidence attributed to protected main. Governance and readiness report paths also retain their existing non-symlink private-output authority. The product/technical gap baseline is refreshed to the same protected-main and live issue/run/release observation, and describes the hourly loop through `contextual-orchestrator` rather than retired direct-provider execution. diff --git a/scripts/external-scheduler-evidence-audit.mjs b/scripts/external-scheduler-evidence-audit.mjs old mode 100644 new mode 100755 index ea5765506..93c02c320 --- a/scripts/external-scheduler-evidence-audit.mjs +++ b/scripts/external-scheduler-evidence-audit.mjs @@ -3,6 +3,7 @@ import { closeSync, constants, fstatSync, + lstatSync, mkdirSync, mkdtempSync, openSync, @@ -13,6 +14,10 @@ import { } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { + assertAcquisitionPrivatePathParents, + writeAcquisitionPrivateFile, +} from "./lib/acquisition-private-output.mjs"; import { evaluateExternalSchedulerEvidence } from "./lib/external-scheduler-evidence-audit.mjs"; import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; @@ -25,6 +30,7 @@ const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); const defaultReadIo = { openSync, fstatSync, + lstatSync, readFileSync, closeSync, }; @@ -53,7 +59,7 @@ export function sanitizeReportText(value) { ? value : ""; const text = rawText - .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(/[\u0000-\u001f\u007f\u2028\u2029]|\p{Cf}/gu, "") .trim(); return text.length <= MAX_ERROR_CHARS ? text @@ -61,12 +67,18 @@ export function sanitizeReportText(value) { } /** - * Read one regular, no-follow, size-bounded UTF-8 JSON evidence file and - * reject descriptor metadata drift observed after the bytes are consumed. + * Read one regular, single-link, no-follow, size-bounded UTF-8 JSON evidence file and + * reject descriptor metadata, retained-leaf identity, or parent-path authority drift + * before returning parsed evidence. */ export function readExternalSchedulerEvidence(path, io = defaultReadIo) { const absolutePath = resolve(path); + if (io === defaultReadIo) { + assertAcquisitionPrivatePathParents(absolutePath); + } let descriptor; + let acceptedMetadata; + let parsedEvidence; try { descriptor = io.openSync( absolutePath, @@ -76,6 +88,9 @@ export function readExternalSchedulerEvidence(path, io = defaultReadIo) { if (!stats.isFile()) { throw new Error("External scheduler evidence must be a regular file."); } + if (stats.nlink !== 1) { + throw new Error("External scheduler evidence must have exactly one filesystem link."); + } if (stats.size <= 0 || stats.size > MAX_EVIDENCE_BYTES) { throw new Error( `External scheduler evidence must contain 1 through ${MAX_EVIDENCE_BYTES} bytes.`, @@ -88,6 +103,7 @@ export function readExternalSchedulerEvidence(path, io = defaultReadIo) { const finalStats = io.fstatSync(descriptor); if ( !finalStats.isFile() + || finalStats.nlink !== 1 || finalStats.dev !== stats.dev || finalStats.ino !== stats.ino || finalStats.size !== stats.size @@ -96,29 +112,75 @@ export function readExternalSchedulerEvidence(path, io = defaultReadIo) { ) { throw new Error("External scheduler evidence changed while it was being read."); } + if (io === defaultReadIo) { + assertAcquisitionPrivatePathParents(absolutePath); + } const text = fatalUtf8Decoder.decode(bytes); if (hasDuplicateJsonObjectKeys(text)) { throw new Error( "External scheduler evidence contains duplicate decoded JSON object keys.", ); } - return JSON.parse(text); + parsedEvidence = JSON.parse(text); + acceptedMetadata = finalStats; } finally { if (descriptor !== undefined) io.closeSync(descriptor); } + + if (typeof io.lstatSync === "function") { + if (io === defaultReadIo) { + assertAcquisitionPrivatePathParents(absolutePath); + } + const retainedMetadata = io.lstatSync( + absolutePath, + { throwIfNoEntry: false }, + ) ?? null; + if ( + !retainedMetadata + || !retainedMetadata.isFile() + || retainedMetadata.nlink !== 1 + || retainedMetadata.dev !== acceptedMetadata.dev + || retainedMetadata.ino !== acceptedMetadata.ino + || retainedMetadata.size !== acceptedMetadata.size + || retainedMetadata.mtimeMs !== acceptedMetadata.mtimeMs + || retainedMetadata.ctimeMs !== acceptedMetadata.ctimeMs + ) { + throw new Error("External scheduler retained pathname changed after it was read."); + } + if (io === defaultReadIo) { + assertAcquisitionPrivatePathParents(absolutePath); + } + } + + return parsedEvidence; } -/** Atomically publish a private JSON report and always remove temporary state. */ +/** Atomically publish a private JSON report without following unsafe output authority. */ export function writeAtomicJson(path, value, io = defaultWriteIo) { const absolutePath = resolve(path); const directory = dirname(absolutePath); + const contents = `${JSON.stringify(value, null, 2)}\n`; + + if (io === defaultWriteIo) { + assertAcquisitionPrivatePathParents(absolutePath); + io.mkdirSync(directory, { recursive: true, mode: 0o700 }); + assertAcquisitionPrivatePathParents(absolutePath); + // The audit report is a one-shot receipt. Exclusive creation is required: + // replacing an existing target could destroy the accepted source inode if + // another process moved that inode onto the report pathname after reading. + writeAcquisitionPrivateFile(absolutePath, contents, undefined, { + replaceExisting: false, + }); + return absolutePath; + } + io.mkdirSync(directory, { recursive: true }); const temporaryDirectory = io.mkdtempSync(join(directory, ".scheduler-audit-")); const temporaryPath = join(temporaryDirectory, "report.json"); try { io.writeFileSync( temporaryPath, - `${JSON.stringify(value, null, 2)}\n`, + contents, { encoding: "utf8", mode: 0o600, flag: "wx" }, ); io.renameSync(temporaryPath, absolutePath); @@ -141,6 +203,30 @@ export function resolveCliPaths(env, argv) { }; } +/** Refuse one filesystem object from serving as both retained source evidence and audit output. */ +export function assertDistinctEvidenceAndReportPaths(evidencePath, reportPath) { + const absoluteEvidencePath = resolve(evidencePath); + const absoluteReportPath = resolve(reportPath); + if (absoluteEvidencePath === absoluteReportPath) { + throw new Error( + "External scheduler evidence and audit report must resolve to different paths.", + ); + } + + const evidenceMetadata = lstatSync(absoluteEvidencePath, { throwIfNoEntry: false }) ?? null; + const reportMetadata = lstatSync(absoluteReportPath, { throwIfNoEntry: false }) ?? null; + if ( + evidenceMetadata + && reportMetadata + && evidenceMetadata.dev === reportMetadata.dev + && evidenceMetadata.ino === reportMetadata.ino + ) { + throw new Error( + "External scheduler evidence and audit report must identify different filesystem objects.", + ); + } +} + /** Build a bounded collection-failure report without retaining raw evidence. */ export function createFailureReport(error, generatedAt) { return { @@ -202,6 +288,7 @@ export function main(options = {}) { process.exitCode = code; }); const { evidencePath, reportPath } = resolveCliPaths(env, argv); + assertDistinctEvidenceAndReportPaths(evidencePath, reportPath); const generatedAt = now(); let report; diff --git a/scripts/lib/acquisition-private-output.mjs b/scripts/lib/acquisition-private-output.mjs index e4486c4cf..dd4224f10 100644 --- a/scripts/lib/acquisition-private-output.mjs +++ b/scripts/lib/acquisition-private-output.mjs @@ -282,6 +282,7 @@ export function writeAcquisitionPrivateFile( path, contents, fileSystem = defaultFileSystem, + options = {}, ) { if (typeof path !== "string" || path.length === 0 || typeof contents !== "string") { throw new TypeError("acquisition output requires a non-empty path and UTF-8 text"); @@ -317,6 +318,10 @@ export function writeAcquisitionPrivateFile( return; } + if (options.replaceExisting === false) { + throw new Error("acquisition output target must not already exist"); + } + if (typeof fileSystem.renameSync !== "function" || typeof fileSystem.unlinkSync !== "function") { throw new Error("acquisition output replacement requires atomic rename filesystem support"); } diff --git a/test/external-scheduler-evidence-cli-defaults.test.ts b/test/external-scheduler-evidence-cli-defaults.test.ts index daee1c2c1..1327bf6ef 100644 --- a/test/external-scheduler-evidence-cli-defaults.test.ts +++ b/test/external-scheduler-evidence-cli-defaults.test.ts @@ -98,6 +98,7 @@ describe("external scheduler evidence CLI production defaults", () => { expect(JSON.parse(readFileSync(reportPath, "utf8")).status).toBe("PASS"); rmSync("external-scheduler-evidence.json"); + rmSync(reportPath); const failReport = cli.main(); expect(failReport.status).toBe("FAIL"); diff --git a/test/external-scheduler-evidence-cli.test.ts b/test/external-scheduler-evidence-cli.test.ts index 461ed7d37..5290a64ae 100644 --- a/test/external-scheduler-evidence-cli.test.ts +++ b/test/external-scheduler-evidence-cli.test.ts @@ -115,7 +115,7 @@ describe("external scheduler evidence CLI", () => { const closed: number[] = []; const io = { openSync: () => 17, - fstatSync: () => ({ isFile: () => true, size: 3 }), + fstatSync: () => ({ isFile: () => true, nlink: 1, size: 3 }), readFileSync: () => Buffer.from("{}", "utf8"), closeSync: (descriptor: number) => closed.push(descriptor), }; diff --git a/test/external-scheduler-evidence-path-collision.test.ts b/test/external-scheduler-evidence-path-collision.test.ts new file mode 100644 index 000000000..2ab5f184c --- /dev/null +++ b/test/external-scheduler-evidence-path-collision.test.ts @@ -0,0 +1,169 @@ +import { + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + main, + readExternalSchedulerEvidence, + writeAtomicJson, +} from "../scripts/external-scheduler-evidence-audit.mjs"; + +const temporaryDirectories: string[] = []; + +function temporaryDirectory() { + const directory = mkdtempSync(join(tmpdir(), "noema-scheduler-path-collision-")); + temporaryDirectories.push(directory); + return directory; +} + +function passingEvidence() { + return { + schema_version: 1, + scheduler_task_identity: "chatgpt-task:noema-hourly-primary", + prompt_sha256: "a".repeat(64), + scheduled_at: "2026-08-10T11:00:00.000Z", + started_at: "2026-08-10T11:00:05.000Z", + repository_full_name: "ContextualWisdomLab/noema", + protected_main_sha: "b".repeat(40), + generic_error_observed: false, + safe_independent_lane_count: 1, + github_actions_performed: [ + { + action_identity: "issue:96", + action_kind: "issue_created", + target_repository: "ContextualWisdomLab/noema", + target_ref: "issues/96", + }, + ], + deferred_lanes: [], + termination_reason: "double_exit_sweep", + exit_sweep_count: 2, + remaining_non_actionable_reasons: ["independent_approval_unavailable"], + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("external scheduler evidence path authority", () => { + it("refuses to replace the retained source evidence with its audit report", () => { + const directory = temporaryDirectory(); + const evidencePath = join(directory, "external-scheduler-evidence.json"); + const evidenceBytes = `${JSON.stringify(passingEvidence())}\n`; + writeFileSync(evidencePath, evidenceBytes, "utf8"); + + expect(() => main({ + env: { + NOEMA_EXTERNAL_SCHEDULER_EVIDENCE_PATH: evidencePath, + NOEMA_EXTERNAL_SCHEDULER_AUDIT_PATH: evidencePath, + }, + argv: ["node", "script"], + now: () => "2026-08-10T11:31:00.000Z", + writeOutput: () => undefined, + setExitCode: () => undefined, + })).toThrow("must resolve to different paths"); + + expect(readFileSync(evidencePath, "utf8")).toBe(evidenceBytes); + }); + + it("rejects different pathnames that already identify the same retained evidence inode", () => { + const directory = temporaryDirectory(); + const evidencePath = join(directory, "external-scheduler-evidence.json"); + const reportPath = join(directory, "external-scheduler-audit.json"); + const evidenceBytes = `${JSON.stringify(passingEvidence())}\n`; + writeFileSync(evidencePath, evidenceBytes, "utf8"); + linkSync(evidencePath, reportPath); + + expect(() => main({ + env: { + NOEMA_EXTERNAL_SCHEDULER_EVIDENCE_PATH: evidencePath, + NOEMA_EXTERNAL_SCHEDULER_AUDIT_PATH: reportPath, + }, + argv: ["node", "script"], + now: () => "2026-08-10T11:31:00.000Z", + writeOutput: () => undefined, + setExitCode: () => undefined, + })).toThrow("must identify different filesystem objects"); + + expect(readFileSync(evidencePath, "utf8")).toBe(evidenceBytes); + expect(readFileSync(reportPath, "utf8")).toBe(evidenceBytes); + }); + + it("does not replace accepted evidence moved onto the report path before publication", () => { + const directory = temporaryDirectory(); + const evidencePath = join(directory, "external-scheduler-evidence.json"); + const reportPath = join(directory, "external-scheduler-audit.json"); + const evidenceBytes = `${JSON.stringify(passingEvidence())}\n`; + writeFileSync(evidencePath, evidenceBytes, "utf8"); + + expect(() => main({ + env: { + NOEMA_EXTERNAL_SCHEDULER_EVIDENCE_PATH: evidencePath, + NOEMA_EXTERNAL_SCHEDULER_AUDIT_PATH: reportPath, + }, + argv: ["node", "script"], + now: () => "2026-08-10T11:31:00.000Z", + readEvidence: (path: string) => { + const evidence = readExternalSchedulerEvidence(path); + renameSync(evidencePath, reportPath); + return evidence; + }, + writeOutput: () => undefined, + setExitCode: () => undefined, + })).toThrow("target must not already exist"); + + expect(readFileSync(reportPath, "utf8")).toBe(evidenceBytes); + }); + + it("does not replace an existing scheduler audit receipt", () => { + const directory = temporaryDirectory(); + const reportPath = join(directory, "external-scheduler-audit.json"); + writeFileSync(reportPath, "preserve-receipt\n", "utf8"); + + expect(() => writeAtomicJson(reportPath, { status: "PASS" })).toThrow( + "target must not already exist", + ); + expect(readFileSync(reportPath, "utf8")).toBe("preserve-receipt\n"); + }); + + it("does not traverse a symlinked retained-evidence parent", () => { + const directory = temporaryDirectory(); + const outsideDirectory = join(directory, "outside"); + mkdirSync(outsideDirectory); + const evidenceBytes = `${JSON.stringify(passingEvidence())}\n`; + writeFileSync(join(outsideDirectory, "evidence.json"), evidenceBytes, "utf8"); + const linkedDirectory = join(directory, "retained"); + symlinkSync(outsideDirectory, linkedDirectory, "dir"); + + expect(() => readExternalSchedulerEvidence(join(linkedDirectory, "evidence.json"))).toThrow( + "parent must be a real directory", + ); + }); + + it("does not traverse a symlinked audit-report parent", () => { + const directory = temporaryDirectory(); + const outsideDirectory = join(directory, "outside"); + mkdirSync(outsideDirectory); + const linkedDirectory = join(directory, "reports"); + symlinkSync(outsideDirectory, linkedDirectory, "dir"); + const reportPath = join(linkedDirectory, "audit.json"); + const outsideReportPath = join(outsideDirectory, "audit.json"); + + expect(() => writeAtomicJson(reportPath, { status: "PASS" })).toThrow( + "parent must be a real directory", + ); + expect(() => readFileSync(outsideReportPath, "utf8")).toThrow(); + }); +}); diff --git a/test/external-scheduler-evidence-post-read-stability.test.ts b/test/external-scheduler-evidence-post-read-stability.test.ts index 0cc486b18..73a1835ae 100644 --- a/test/external-scheduler-evidence-post-read-stability.test.ts +++ b/test/external-scheduler-evidence-post-read-stability.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; const cliUrl = new URL( @@ -15,6 +16,7 @@ type MetadataOverrides = Partial<{ size: number; mtimeMs: number; ctimeMs: number; + nlink: number; file: boolean; }>; @@ -25,6 +27,7 @@ function metadata(overrides: MetadataOverrides = {}) { size: overrides.size ?? 2, mtimeMs: overrides.mtimeMs ?? 17, ctimeMs: overrides.ctimeMs ?? 19, + nlink: overrides.nlink ?? 1, isFile: () => overrides.file ?? true, }; } @@ -32,6 +35,7 @@ function metadata(overrides: MetadataOverrides = {}) { describe("external scheduler evidence descriptor post-read stability", () => { it.each([ { label: "non-file metadata", finalMetadata: metadata({ file: false }) }, + { label: "link-count drift", finalMetadata: metadata({ nlink: 2 }) }, { label: "device drift", finalMetadata: metadata({ dev: 99 }) }, { label: "inode drift", finalMetadata: metadata({ ino: 99 }) }, { label: "size drift", finalMetadata: metadata({ size: 3 }) }, @@ -57,4 +61,104 @@ describe("external scheduler evidence descriptor post-read stability", () => { expect(fstatSync).toHaveBeenCalledTimes(2); expect(closed).toEqual([31]); }); + + it.each([ + { label: "missing leaf", retainedMetadata: null }, + { label: "non-file leaf", retainedMetadata: metadata({ file: false }) }, + { label: "link-count drift", retainedMetadata: metadata({ nlink: 2 }) }, + { label: "device drift", retainedMetadata: metadata({ dev: 99 }) }, + { label: "inode drift", retainedMetadata: metadata({ ino: 99 }) }, + { label: "size drift", retainedMetadata: metadata({ size: 3 }) }, + { label: "mtime drift", retainedMetadata: metadata({ mtimeMs: 23 }) }, + { label: "ctime drift", retainedMetadata: metadata({ ctimeMs: 29 }) }, + ])("rejects retained pathname $label after descriptor acceptance", async ({ retainedMetadata }) => { + const cli = await loadCli(); + const closed: number[] = []; + const fstatSync = vi + .fn() + .mockReturnValueOnce(metadata()) + .mockReturnValueOnce(metadata()); + const lstatSync = vi.fn().mockReturnValue(retainedMetadata); + const io = { + openSync: () => 31, + fstatSync, + lstatSync, + readFileSync: () => Buffer.from("{}", "utf8"), + closeSync: (descriptor: number) => closed.push(descriptor), + }; + + expect(() => cli.readExternalSchedulerEvidence("ignored.json", io)).toThrow( + "retained pathname changed after it was read", + ); + expect(closed).toEqual([31]); + expect(lstatSync).toHaveBeenCalledTimes(1); + }); + + it("returns parsed evidence when a custom reader has no retained-path lstat capability", async () => { + const cli = await loadCli(); + const closed: number[] = []; + const fstatSync = vi + .fn() + .mockReturnValueOnce(metadata()) + .mockReturnValueOnce(metadata()); + const io = { + openSync: () => 31, + fstatSync, + readFileSync: () => Buffer.from("{}", "utf8"), + closeSync: (descriptor: number) => closed.push(descriptor), + }; + + expect(cli.readExternalSchedulerEvidence("ignored.json", io)).toEqual({}); + expect(fstatSync).toHaveBeenCalledTimes(2); + expect(closed).toEqual([31]); + }); + + it("returns the resolved report path after a custom atomic publication succeeds", async () => { + const cli = await loadCli(); + const reportPath = resolve("custom-scheduler-audit.json"); + const temporaryDirectory = resolve(".scheduler-audit-fixture"); + const io = { + mkdirSync: vi.fn(), + mkdtempSync: vi.fn().mockReturnValue(temporaryDirectory), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), + }; + + expect(cli.writeAtomicJson(reportPath, { status: "PASS" }, io)).toBe(reportPath); + expect(io.writeFileSync).toHaveBeenCalledWith( + resolve(temporaryDirectory, "report.json"), + expect.stringContaining('"status": "PASS"'), + { encoding: "utf8", mode: 0o600, flag: "wx" }, + ); + expect(io.renameSync).toHaveBeenCalledWith( + resolve(temporaryDirectory, "report.json"), + reportPath, + ); + expect(io.rmSync).toHaveBeenCalledWith( + temporaryDirectory, + { recursive: true, force: true }, + ); + }); + + it("returns parsed evidence only after the retained pathname remains the same safe inode", async () => { + const cli = await loadCli(); + const closed: number[] = []; + const fstatSync = vi + .fn() + .mockReturnValueOnce(metadata()) + .mockReturnValueOnce(metadata()); + const lstatSync = vi.fn().mockReturnValue(metadata()); + const io = { + openSync: () => 31, + fstatSync, + lstatSync, + readFileSync: () => Buffer.from("{}", "utf8"), + closeSync: (descriptor: number) => closed.push(descriptor), + }; + + expect(cli.readExternalSchedulerEvidence("ignored.json", io)).toEqual({}); + expect(closed).toEqual([31]); + expect(lstatSync).toHaveBeenCalledTimes(1); + }); }); diff --git a/test/external-scheduler-evidence-sanitization.test.ts b/test/external-scheduler-evidence-sanitization.test.ts new file mode 100644 index 000000000..00e29ea71 --- /dev/null +++ b/test/external-scheduler-evidence-sanitization.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { sanitizeReportText } from "../scripts/external-scheduler-evidence-audit.mjs"; + +describe("external scheduler evidence report sanitization", () => { + it("removes Unicode formatting and line-separator controls from retained diagnostics", () => { + const hostile = "before\u202Eevil\u2066after\u2028line\u2029para"; + + expect(sanitizeReportText(hostile)).toBe("beforeevilafterlinepara"); + }); +}); diff --git a/test/external-scheduler-evidence-source-hardlink.test.ts b/test/external-scheduler-evidence-source-hardlink.test.ts new file mode 100644 index 000000000..c07a6e192 --- /dev/null +++ b/test/external-scheduler-evidence-source-hardlink.test.ts @@ -0,0 +1,28 @@ +import { + linkSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readExternalSchedulerEvidence } from "../scripts/external-scheduler-evidence-audit.mjs"; + +describe("external scheduler retained-source inode authority", () => { + it("rejects a retained evidence pathname that is hardlinked to another file", () => { + const root = mkdtempSync(join(tmpdir(), "noema-scheduler-source-hardlink-")); + try { + const retainedPath = join(root, "external-scheduler-evidence.json"); + const aliasPath = join(root, "buyer-evidence.json"); + writeFileSync(retainedPath, "{\"schema_version\":1}\n", "utf8"); + linkSync(retainedPath, aliasPath); + + expect(() => readExternalSchedulerEvidence(retainedPath)).toThrow( + "External scheduler evidence must have exactly one filesystem link.", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});