From 3e8e18cc5596ed959c1b6bf3a1ccc0f286cf395a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:05:58 +0900 Subject: [PATCH 1/4] fix(operations): restack runner JSON integrity on f1846 main --- scripts/actions-runner-assignment-audit.mjs | 168 ++++++++-- test/actions-runner-assignment-cli.test.ts | 321 ++++++++++++++++++-- vitest.config.ts | 1 + 3 files changed, 440 insertions(+), 50 deletions(-) diff --git a/scripts/actions-runner-assignment-audit.mjs b/scripts/actions-runner-assignment-audit.mjs index 77457a543..1be13757d 100644 --- a/scripts/actions-runner-assignment-audit.mjs +++ b/scripts/actions-runner-assignment-audit.mjs @@ -21,6 +21,7 @@ import { collectRunnerAssignmentEvidence, parseSelectedRunIds, } from "./lib/actions-runner-assignment-source.mjs"; +import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; const AUDITED_REPOSITORY = "ContextualWisdomLab/noema"; const GITHUB_API_VERSION = "2026-03-10"; @@ -28,6 +29,22 @@ const GH_API_TIMEOUT_MILLISECONDS = 20_000; const GH_API_MAX_BUFFER_BYTES = 2 * 1024 * 1024; const REPORT_PATH = "artifacts/operations/actions-runner-assignment-audit.json"; const canonicalShaPattern = /^[0-9a-f]{40}$/; +const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +const defaultGhRuntime = { + spawn_sync: spawnSync, + environment: process.env, +}; + +const defaultWriteIo = { + mkdirSync, + openSync, + writeFileSync, + closeSync, + renameSync, + unlinkSync, + randomUUID, +}; function boundedErrorText(value) { const text = typeof value === "string" ? value : String(value ?? ""); @@ -66,20 +83,69 @@ export function createGhSubprocessEnvironment(environment) { }; } +/** + * Decode and parse bounded GitHub API bytes without normalizing ambiguous input. + * + * Malformed UTF-8 and duplicate decoded object keys fail before `JSON.parse`, so + * runner-assignment evidence cannot inherit replacement-character or + * last-key-wins semantics from the JavaScript runtime. + * + * @param {Uint8Array} bytes Raw stdout bytes returned by the GitHub CLI. + * @returns {unknown} Parsed JSON evidence. + */ +export function parseGhJsonEvidence(bytes) { + if (!(bytes instanceof Uint8Array)) { + throw new TypeError("GitHub Actions evidence must be supplied as raw bytes."); + } + + let text; + try { + text = fatalUtf8Decoder.decode(bytes); + } catch { + throw new Error("GitHub Actions evidence read returned invalid UTF-8."); + } + + let duplicateKeys; + try { + duplicateKeys = hasDuplicateJsonObjectKeys(text); + } catch { + throw new Error("GitHub Actions evidence read returned malformed JSON."); + } + if (duplicateKeys) { + throw new Error("GitHub Actions evidence read returned duplicate decoded object keys."); + } + + try { + return JSON.parse(text); + } catch { + throw new Error("GitHub Actions evidence read returned malformed JSON."); + } +} + /** * Read one GitHub REST resource through the authenticated `gh` CLI. * * The caller supplies only repository-relative API paths. Pagination uses * `--slurp` so every returned page remains explicit to the bounded source - * collector instead of being silently collapsed or truncated. + * collector instead of being silently collapsed or truncated. The optional + * runtime is an explicit test seam only; production callers use the pinned + * shell-free `spawnSync` runtime and least-authority environment. + * + * @param {string} path Repository-relative GitHub REST path. + * @param {{paginate?: boolean}} options Read options. + * @param {{spawn_sync?: Function, environment?: object}} runtime Process runtime. + * @returns {unknown} Parsed GitHub JSON evidence. */ -export function ghApi(path, options = {}) { +export function ghApi(path, options = {}, runtime = defaultGhRuntime) { if (typeof path !== "string" || path.length === 0 || path.length > 1000) { throw new Error("GitHub API path is invalid."); } if (path.startsWith("/") || path.includes("..") || /[\u0000-\u001f\u007f]/.test(path)) { throw new Error("GitHub API path is outside the bounded relative-path contract."); } + if (!runtime || typeof runtime.spawn_sync !== "function") { + throw new Error("A shell-free GitHub CLI spawn runtime is required."); + } const args = [ "api", @@ -93,11 +159,10 @@ export function ghApi(path, options = {}) { } args.push(path); - const result = spawnSync("gh", args, { - encoding: "utf8", + const result = runtime.spawn_sync("gh", args, { timeout: GH_API_TIMEOUT_MILLISECONDS, maxBuffer: GH_API_MAX_BUFFER_BYTES, - env: createGhSubprocessEnvironment(process.env), + env: createGhSubprocessEnvironment(runtime.environment ?? process.env), stdio: ["ignore", "pipe", "pipe"], }); @@ -110,11 +175,7 @@ export function ghApi(path, options = {}) { ); } - try { - return JSON.parse(result.stdout); - } catch { - throw new Error("GitHub Actions evidence read returned malformed JSON."); - } + return parseGhJsonEvidence(result.stdout); } /** @@ -163,33 +224,43 @@ function parseQueueGrace(value) { return parsed; } -/** Write the fixed audit report atomically with owner-only temporary permissions. */ -export function writeReportAtomically(report) { +/** + * Write the fixed audit report atomically with owner-only temporary permissions. + * + * The optional I/O seam permits deterministic failure testing without changing + * the production report path, file mode, atomic rename, or cleanup semantics. + * + * @param {unknown} report Bounded report value. + * @param {object} io File-system and UUID operations. + * @returns {string} Absolute report path. + */ +export function writeReportAtomically(report, io = defaultWriteIo) { const reportPath = resolve(REPORT_PATH); const reportDirectory = dirname(reportPath); - mkdirSync(reportDirectory, { recursive: true, mode: 0o700 }); - const temporaryPath = `${reportPath}.tmp-${process.pid}-${randomUUID()}`; + io.mkdirSync(reportDirectory, { recursive: true, mode: 0o700 }); + const temporaryPath = `${reportPath}.tmp-${process.pid}-${io.randomUUID()}`; let descriptor; try { - descriptor = openSync(temporaryPath, "wx", 0o600); - writeFileSync(descriptor, `${JSON.stringify(report, null, 2)}\n`, "utf8"); - closeSync(descriptor); + descriptor = io.openSync(temporaryPath, "wx", 0o600); + io.writeFileSync(descriptor, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + io.closeSync(descriptor); descriptor = undefined; - renameSync(temporaryPath, reportPath); + io.renameSync(temporaryPath, reportPath); } finally { if (descriptor !== undefined) { try { - closeSync(descriptor); + io.closeSync(descriptor); } catch { // Cleanup failures must not replace the original report-write failure. } } try { - unlinkSync(temporaryPath); + io.unlinkSync(temporaryPath); } catch { // Cleanup failures must not replace the original report-write result. } } + return reportPath; } /** @@ -270,21 +341,54 @@ export async function runActionsRunnerAssignmentAudit(input) { }; } -async function main() { +/** + * Execute the CLI with injectable boundaries while preserving production defaults. + * + * @param {object} options Runtime overrides used only by tests/operators. + * @returns {Promise<{exit_code: number, report: object}>} Audit result. + */ +export async function main(options = {}) { const result = await runActionsRunnerAssignmentAudit({ - env: process.env, - observed_at: new Date().toISOString(), - gh_api: ghApi, - write_report: writeReportAtomically, + env: options.env ?? process.env, + observed_at: options.observed_at ?? new Date().toISOString(), + gh_api: options.gh_api ?? ghApi, + write_report: options.write_report ?? writeReportAtomically, }); - process.stdout.write(`${result.report.status}\n`); - process.exitCode = result.exit_code; + const writeOutput = options.write_output ?? ((value) => process.stdout.write(value)); + const setExitCode = options.set_exit_code ?? ((code) => { + process.exitCode = code; + }); + writeOutput(`${result.report.status}\n`); + setExitCode(result.exit_code); + return result; } -const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; -if (invokedPath === import.meta.url) { - main().catch((error) => { - process.stderr.write(`runner-assignment audit failed: ${boundedErrorText(error?.message)}\n`); - process.exitCode = 2; +/** + * Run a CLI promise with bounded error output and a distinct internal-error exit. + * + * @param {object} options Execution/output overrides. + * @returns {Promise} Execution result, or undefined after a bounded error. + */ +export async function startCli(options = {}) { + const execute = options.execute ?? main; + const writeError = options.write_error ?? ((value) => process.stderr.write(value)); + const setExitCode = options.set_exit_code ?? ((code) => { + process.exitCode = code; }); + try { + return await execute(); + } catch (error) { + writeError(`runner-assignment audit failed: ${boundedErrorText(error?.message)}\n`); + setExitCode(2); + return undefined; + } } + +/** Execute a supplied CLI only when the module is the process entry point. */ +export function runIfDirect(metaUrl, argv, execute) { + if (!argv[1] || metaUrl !== pathToFileURL(resolve(argv[1])).href) return false; + void execute(); + return true; +} + +runIfDirect(import.meta.url, process.argv, startCli); diff --git a/test/actions-runner-assignment-cli.test.ts b/test/actions-runner-assignment-cli.test.ts index 1b0b51ea4..028cb6bf1 100644 --- a/test/actions-runner-assignment-cli.test.ts +++ b/test/actions-runner-assignment-cli.test.ts @@ -1,12 +1,90 @@ -import { describe, expect, it, vi } from "vitest"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createGhReadAdapters, createGhSubprocessEnvironment, ghApi, + main, + parseGhJsonEvidence, runActionsRunnerAssignmentAudit, + runIfDirect, + startCli, + writeReportAtomically, } from "../scripts/actions-runner-assignment-audit.mjs"; const expectedHead = "0123456789abcdef0123456789abcdef01234567"; +const originalCwd = process.cwd(); +const originalEnvironment = { ...process.env }; +const originalExitCode = process.exitCode; + +function restoreEnvironment() { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnvironment)) delete process.env[key]; + } + Object.assign(process.env, originalEnvironment); +} + +function assignedRunApi(path: string) { + if (path.endsWith("/jobs?filter=all&per_page=100")) { + return [{ + jobs: [{ + id: 1001, + name: "verify", + status: "completed", + conclusion: "failure", + started_at: "2026-08-09T23:52:00.000Z", + completed_at: "2026-08-09T23:53:00.000Z", + runner_id: 77, + runner_name: "GitHub Actions 77", + }], + }]; + } + return { + id: 100, + name: "ci", + event: "pull_request", + head_sha: expectedHead, + status: "completed", + conclusion: "failure", + created_at: "2026-08-09T23:50:00.000Z", + }; +} + +function auditEnvironment(overrides: Record = {}) { + return { + GH_TOKEN: "present-but-never-retained", + NOEMA_ACTIONS_AUDIT_REPOSITORY: "ContextualWisdomLab/noema", + NOEMA_ACTIONS_AUDIT_HEAD_SHA: expectedHead, + NOEMA_ACTIONS_AUDIT_RUN_IDS: "100", + ...overrides, + }; +} + +function createGhShim(directory: string) { + const executable = join(directory, "gh"); + writeFileSync(executable, `#!/bin/sh +case "$*" in + *"/jobs?filter=all&per_page=100"*) + printf '%s' '[{"jobs":[{"id":1001,"name":"verify","status":"completed","conclusion":"failure","started_at":"2026-08-09T23:52:00.000Z","completed_at":"2026-08-09T23:53:00.000Z","runner_id":77,"runner_name":"GitHub Actions 77"}]}]' + ;; + *) + printf '%s' '{"id":100,"name":"ci","event":"pull_request","head_sha":"${expectedHead}","status":"completed","conclusion":"failure","created_at":"2026-08-09T23:50:00.000Z"}' + ;; +esac +`, "utf8"); + chmodSync(executable, 0o700); + return executable; +} + +afterEach(() => { + process.chdir(originalCwd); + restoreEnvironment(); + process.exitCode = originalExitCode; + vi.restoreAllMocks(); +}); describe("runner-assignment operator audit", () => { it("uses only bounded read-only workflow-run and fully paginated job endpoints", async () => { @@ -23,15 +101,92 @@ describe("runner-assignment operator audit", () => { expect(ghApiReader).toHaveBeenNthCalledWith(2, "repos/ContextualWisdomLab/noema/actions/runs/100/jobs?filter=all&per_page=100", { paginate: true }); }); + it("fails closed on invalid read-adapter authority", () => { + expect(() => createGhReadAdapters(null)).toThrow("restricted"); + expect(() => createGhReadAdapters({ repository: "ContextualWisdomLab/other", gh_api: vi.fn() })).toThrow("restricted"); + expect(() => createGhReadAdapters({ repository: "ContextualWisdomLab/noema", gh_api: null })).toThrow("read-only"); + }); + it.each([ ["/repos/ContextualWisdomLab/noema/actions/runs/100", "outside"], ["repos/ContextualWisdomLab/noema/../other", "outside"], ["repos/ContextualWisdomLab/noema/actions/runs/100\u0000", "outside"], [`repos/${"a".repeat(1000)}`, "invalid"], + ["", "invalid"], ])("rejects unbounded GitHub API paths before subprocess setup: %s", (path, message) => { expect(() => ghApi(path)).toThrow(message); }); + it("executes the GitHub CLI through an injected shell-free bounded runtime", () => { + const spawn = vi.fn(() => ({ + error: undefined, + status: 0, + stdout: Buffer.from('{"id":100}', "utf8"), + stderr: Buffer.alloc(0), + })); + expect(ghApi( + "repos/ContextualWisdomLab/noema/actions/runs/100/jobs?filter=all&per_page=100", + { paginate: true }, + { spawn_sync: spawn, environment: { PATH: "/usr/bin", GH_TOKEN: "token" } }, + )).toEqual({ id: 100 }); + expect(spawn).toHaveBeenCalledOnce(); + const [command, args, options] = spawn.mock.calls[0]; + expect(command).toBe("gh"); + expect(args).toContain("--paginate"); + expect(args).toContain("--slurp"); + expect(options).toMatchObject({ + timeout: 20_000, + maxBuffer: 2 * 1024 * 1024, + env: { PATH: "/usr/bin", GH_TOKEN: "token", GH_HOST: "github.com", NO_COLOR: "1" }, + stdio: ["ignore", "pipe", "pipe"], + }); + }); + + it("fails closed on missing runtime, spawn errors, and nonzero gh exits", () => { + expect(() => ghApi( + "repos/ContextualWisdomLab/noema/actions/runs/100", + {}, + { spawn_sync: null, environment: { PATH: "/usr/bin", GH_TOKEN: "token" } }, + )).toThrow("spawn runtime"); + + expect(() => ghApi( + "repos/ContextualWisdomLab/noema/actions/runs/100", + {}, + { + spawn_sync: () => ({ error: new Error("spawn failed\u0000"), status: null, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }), + environment: { PATH: "/usr/bin", GH_TOKEN: "token" }, + }, + )).toThrow("spawn failed"); + + expect(() => ghApi( + "repos/ContextualWisdomLab/noema/actions/runs/100", + {}, + { + spawn_sync: () => ({ error: undefined, status: 7, stdout: Buffer.alloc(0), stderr: Buffer.from("bad\u0000stderr\n", "utf8") }), + environment: { PATH: "/usr/bin", GH_TOKEN: "token" }, + }, + )).toThrow("gh exit 7: bad stderr"); + }); + + it("rejects malformed UTF-8, malformed JSON, and duplicate decoded keys in GitHub API evidence", () => { + expect(() => parseGhJsonEvidence("not-bytes" as unknown as Uint8Array)).toThrow("raw bytes"); + expect(() => parseGhJsonEvidence(Buffer.concat([ + Buffer.from('{"id":100,"name":"', "utf8"), + Buffer.from([0xff]), + Buffer.from('"}', "utf8"), + ]))).toThrow("invalid UTF-8"); + + expect(() => parseGhJsonEvidence(Buffer.from('{"id":100,"i\\u0064":101}', "utf8"))).toThrow( + "duplicate decoded object keys", + ); + expect(() => parseGhJsonEvidence(Buffer.from('{"id":', "utf8"))).toThrow("malformed JSON"); + + expect(parseGhJsonEvidence(Buffer.from('{"id":100,"head_sha":"0123456789abcdef0123456789abcdef01234567"}', "utf8"))).toEqual({ + id: 100, + head_sha: expectedHead, + }); + }); + it("isolates the gh subprocess from unrelated repository, model, and proxy credentials", () => { expect(createGhSubprocessEnvironment({ PATH: "/usr/bin:/bin", @@ -55,20 +210,75 @@ describe("runner-assignment operator audit", () => { expect(() => createGhSubprocessEnvironment({ PATH: "/usr/bin" })).toThrow("GH_TOKEN"); }); - it("rejects queue grace beyond the evaluator maximum before GitHub access", async () => { + it("publishes reports atomically and preserves the original failure through cleanup", () => { + const normalIo = { + mkdirSync: vi.fn(), + openSync: vi.fn(() => 41), + writeFileSync: vi.fn(), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(() => { throw new Error("already renamed"); }), + randomUUID: vi.fn(() => "uuid"), + }; + expect(writeReportAtomically({ status: "PASS" }, normalIo)).toContain("actions-runner-assignment-audit.json"); + expect(normalIo.closeSync).toHaveBeenCalledWith(41); + expect(normalIo.renameSync).toHaveBeenCalledOnce(); + + const cleanupClose = vi.fn(() => { throw new Error("cleanup close failed"); }); + const cleanupUnlink = vi.fn(); + const failingIo = { + ...normalIo, + openSync: vi.fn(() => 42), + closeSync: cleanupClose, + unlinkSync: cleanupUnlink, + }; + expect(() => writeReportAtomically({ value: 1n }, failingIo)).toThrow("BigInt"); + expect(cleanupClose).toHaveBeenCalledWith(42); + expect(cleanupUnlink).toHaveBeenCalledOnce(); + }); + + it("rejects malformed operator inputs before GitHub access", async () => { const ghApiReader = vi.fn(); + const writer = vi.fn(); + await expect(runActionsRunnerAssignmentAudit(null)).rejects.toThrow("input"); + await expect(runActionsRunnerAssignmentAudit({ env: null })).rejects.toThrow("environment"); + await expect(runActionsRunnerAssignmentAudit({ env: auditEnvironment({ GH_TOKEN: "" }) })).rejects.toThrow("GH_TOKEN"); await expect(runActionsRunnerAssignmentAudit({ - env: { - GH_TOKEN: "token", - NOEMA_ACTIONS_AUDIT_REPOSITORY: "ContextualWisdomLab/noema", - NOEMA_ACTIONS_AUDIT_HEAD_SHA: expectedHead, - NOEMA_ACTIONS_AUDIT_RUN_IDS: "100", - NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "1800001", - }, + env: auditEnvironment({ NOEMA_ACTIONS_AUDIT_HEAD_SHA: "ABC" }), observed_at: "2026-08-10T00:00:00.000Z", gh_api: ghApiReader, - write_report: vi.fn(), + write_report: writer, + })).rejects.toThrow("canonical lowercase"); + await expect(runActionsRunnerAssignmentAudit({ + env: auditEnvironment({ NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "0" }), + observed_at: "2026-08-10T00:00:00.000Z", + gh_api: ghApiReader, + write_report: writer, + })).rejects.toThrow("positive integer"); + await expect(runActionsRunnerAssignmentAudit({ + env: auditEnvironment({ NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "9007199254740992" }), + observed_at: "2026-08-10T00:00:00.000Z", + gh_api: ghApiReader, + write_report: writer, + })).rejects.toThrow("safe integer"); + await expect(runActionsRunnerAssignmentAudit({ + env: auditEnvironment({ NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "1800001" }), + observed_at: "2026-08-10T00:00:00.000Z", + gh_api: ghApiReader, + write_report: writer, })).rejects.toThrow("at most 1800000"); + await expect(runActionsRunnerAssignmentAudit({ + env: auditEnvironment(), + observed_at: "not-a-date", + gh_api: ghApiReader, + write_report: writer, + })).rejects.toThrow("parseable timestamp"); + await expect(runActionsRunnerAssignmentAudit({ + env: auditEnvironment(), + observed_at: "2026-08-10T00:00:00.000Z", + gh_api: ghApiReader, + write_report: null, + })).rejects.toThrow("report writer"); expect(ghApiReader).not.toHaveBeenCalled(); }); @@ -81,12 +291,7 @@ describe("runner-assignment operator audit", () => { return { id: 100, name: "ci", event: "pull_request", head_sha: expectedHead, status: "queued", conclusion: null, created_at: "2026-08-09T23:58:00.000Z" }; }); const result = await runActionsRunnerAssignmentAudit({ - env: { - GH_TOKEN: "present-but-never-retained", - NOEMA_ACTIONS_AUDIT_REPOSITORY: "ContextualWisdomLab/noema", - NOEMA_ACTIONS_AUDIT_HEAD_SHA: expectedHead, - NOEMA_ACTIONS_AUDIT_RUN_IDS: "100", - }, + env: auditEnvironment({ NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "" }), observed_at: "2026-08-10T00:00:00.000Z", gh_api: ghApiReader, write_report: writeReport, @@ -97,10 +302,30 @@ describe("runner-assignment operator audit", () => { expect(writeReport).toHaveBeenCalledOnce(); }); + it("returns zero for proven assignment while keeping workflow conclusion authority separate", async () => { + const writeReport = vi.fn(); + const result = await runActionsRunnerAssignmentAudit({ + env: auditEnvironment({ NOEMA_ACTIONS_AUDIT_QUEUE_GRACE_MILLISECONDS: "1000" }), + observed_at: "2026-08-10T00:00:00.000Z", + gh_api: vi.fn(async (path: string) => assignedRunApi(path)), + write_report: writeReport, + }); + expect(result.exit_code).toBe(0); + expect(result.report.status).toBe("PASS"); + expect(result.report.authority).toEqual({ + runner_assignment_only: true, + required_check_success: false, + review_authority: false, + merge_authority: false, + release_authority: false, + deployment_authority: false, + }); + }); + it("fails before GitHub access when repository or credentials are outside the bounded contract", async () => { const ghApiReader = vi.fn(); await expect(runActionsRunnerAssignmentAudit({ - env: { GH_TOKEN: "token", NOEMA_ACTIONS_AUDIT_REPOSITORY: "ContextualWisdomLab/other", NOEMA_ACTIONS_AUDIT_HEAD_SHA: expectedHead, NOEMA_ACTIONS_AUDIT_RUN_IDS: "100" }, + env: { ...auditEnvironment(), NOEMA_ACTIONS_AUDIT_REPOSITORY: "ContextualWisdomLab/other" }, observed_at: "2026-08-10T00:00:00.000Z", gh_api: ghApiReader, write_report: vi.fn(), @@ -113,4 +338,64 @@ describe("runner-assignment operator audit", () => { })).rejects.toThrow("GH_TOKEN"); expect(ghApiReader).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it("runs main with injected output and exit-code boundaries", async () => { + const writeOutput = vi.fn(); + const setExitCode = vi.fn(); + const writeReport = vi.fn(); + const result = await main({ + env: auditEnvironment(), + observed_at: "2026-08-10T00:00:00.000Z", + gh_api: vi.fn(async (path: string) => assignedRunApi(path)), + write_report: writeReport, + write_output: writeOutput, + set_exit_code: setExitCode, + }); + expect(result.exit_code).toBe(0); + expect(writeOutput).toHaveBeenCalledWith("PASS\n"); + expect(setExitCode).toHaveBeenCalledWith(0); + }); + + it("runs the default CLI dependencies against a real local gh shim without credential widening", async () => { + const directory = mkdtempSync(join(tmpdir(), "noema-runner-audit-")); + const previousPath = process.env.PATH ?? ""; + try { + createGhShim(directory); + process.chdir(directory); + Object.assign(process.env, auditEnvironment()); + process.env.PATH = `${directory}:${previousPath}`; + const result = await main(); + expect(result.exit_code).toBe(0); + const reportPath = resolve(directory, "artifacts/operations/actions-runner-assignment-audit.json"); + expect(existsSync(reportPath)).toBe(true); + expect(JSON.parse(readFileSync(reportPath, "utf8"))).toMatchObject({ + status: "PASS", + expected_head_sha: expectedHead, + }); + } finally { + process.chdir(originalCwd); + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("tests direct-entry dispatch independently from CLI error handling", async () => { + const execute = vi.fn(); + expect(runIfDirect("file:///tmp/a.mjs", ["node"], execute)).toBe(false); + expect(runIfDirect("file:///tmp/a.mjs", ["node", "/tmp/b.mjs"], execute)).toBe(false); + expect(runIfDirect(pathToFileURL("/tmp/a.mjs").href, ["node", "/tmp/a.mjs"], execute)).toBe(true); + expect(execute).toHaveBeenCalledOnce(); + + const writeError = vi.fn(); + const setExitCode = vi.fn(); + await expect(startCli({ + execute: async () => { throw new Error("bad\u0000failure"); }, + write_error: writeError, + set_exit_code: setExitCode, + })).resolves.toBeUndefined(); + expect(writeError).toHaveBeenCalledWith("runner-assignment audit failed: bad failure\n"); + expect(setExitCode).toHaveBeenCalledWith(2); + + const success = vi.fn(async () => ({ exit_code: 0 })); + await expect(startCli({ execute: success, write_error: writeError, set_exit_code: setExitCode })).resolves.toEqual({ exit_code: 0 }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index fef24afa4..4e05da1b4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "scripts/normalize-commercial-readiness-evidence.mjs", "scripts/prepare-agent-pr-message.mjs", "scripts/workflow-registry-audit.mjs", + "scripts/actions-runner-assignment-audit.mjs", "scripts/lib/external-scheduler-evidence-audit.mjs", "scripts/external-scheduler-evidence-audit.mjs", ], From 8c6ccf38bf07beb29730d1de68aeb2b57a0ecf54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:19:42 +0900 Subject: [PATCH 2/4] test(operations): cover runner audit fail-closed defaults --- ...actions-runner-assignment-coverage.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 test/actions-runner-assignment-coverage.test.ts diff --git a/test/actions-runner-assignment-coverage.test.ts b/test/actions-runner-assignment-coverage.test.ts new file mode 100644 index 000000000..2b66ced38 --- /dev/null +++ b/test/actions-runner-assignment-coverage.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseGhJsonEvidence, + startCli, +} from "../scripts/actions-runner-assignment-audit.mjs"; + +const originalExitCode = process.exitCode; + +afterEach(() => { + process.exitCode = originalExitCode; + vi.restoreAllMocks(); +}); + +describe("runner-assignment defensive production branches", () => { + it("fails closed when the JSON runtime rejects text after structural integrity scanning", () => { + const jsonParse = vi.spyOn(JSON, "parse").mockImplementationOnce(() => { + throw new SyntaxError("synthetic JSON runtime failure"); + }); + + expect(() => parseGhJsonEvidence(Buffer.from("123", "utf8"))).toThrow( + "GitHub Actions evidence read returned malformed JSON.", + ); + expect(jsonParse).toHaveBeenCalledWith("123"); + }); + + it("sets the process exit code through the production default after an internal CLI failure", async () => { + process.exitCode = undefined; + const writeError = vi.fn(); + + await expect(startCli({ + execute: async () => { + throw new Error("synthetic internal failure"); + }, + write_error: writeError, + })).resolves.toBeUndefined(); + + expect(writeError).toHaveBeenCalledWith( + "runner-assignment audit failed: synthetic internal failure\n", + ); + expect(process.exitCode).toBe(2); + }); +}); From 1264e22416bb74df8826bdd1d5169135d14a6136 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:08:21 +0900 Subject: [PATCH 3/4] test(operations): cover runner audit production boundaries --- ...ner-assignment-production-branches.test.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 test/actions-runner-assignment-production-branches.test.ts diff --git a/test/actions-runner-assignment-production-branches.test.ts b/test/actions-runner-assignment-production-branches.test.ts new file mode 100644 index 000000000..fdcfd9918 --- /dev/null +++ b/test/actions-runner-assignment-production-branches.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + ghApi, + startCli, +} from "../scripts/actions-runner-assignment-audit.mjs"; + +const originalExitCode = process.exitCode; +const originalGithubToken = process.env.GH_TOKEN; + +afterEach(() => { + process.exitCode = originalExitCode; + if (originalGithubToken === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = originalGithubToken; +}); + +describe("runner-assignment production boundary coverage", () => { + it("bounds non-string GitHub CLI spawn diagnostics without leaking ambient state", () => { + expect(() => ghApi( + "repos/ContextualWisdomLab/noema/actions/runs/1", + {}, + { + environment: { PATH: "/usr/bin", GH_TOKEN: "read-only-token" }, + spawn_sync: () => ({ + error: { message: undefined }, + status: null, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + }), + }, + )).toThrow(/GitHub Actions evidence read failed/i); + }); + + it("uses the production process environment only when an injected runtime omits one", () => { + process.env.GH_TOKEN = "read-only-process-token"; + const observedEnvironments: Array> = []; + + const result = ghApi( + "repos/ContextualWisdomLab/noema/actions/runs/2", + {}, + { + spawn_sync: (_command: string, _args: string[], options: { env: Record }) => { + observedEnvironments.push(options.env); + return { + status: 0, + stdout: new TextEncoder().encode('{"id":2}'), + stderr: new Uint8Array(), + }; + }, + }, + ); + + expect(result).toEqual({ id: 2 }); + expect(observedEnvironments).toHaveLength(1); + expect(observedEnvironments[0]).toEqual({ + PATH: process.env.PATH, + GH_TOKEN: "read-only-process-token", + GH_HOST: "github.com", + NO_COLOR: "1", + }); + }); + + it("uses the production stderr and exit-code boundaries for an unhandled CLI failure", async () => { + const previousExitCode = process.exitCode; + try { + const result = await startCli({ + execute: async () => { + throw new Error("synthetic production-boundary failure"); + }, + }); + + expect(result).toBeUndefined(); + expect(process.exitCode).toBe(2); + } finally { + process.exitCode = previousExitCode; + } + }); +}); From d4155927f290811a6453cee54da8ea28cd7d1188 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:27:58 +0900 Subject: [PATCH 4/4] test(operations): cover default runner audit entrypoint --- ...ner-assignment-production-branches.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/actions-runner-assignment-production-branches.test.ts b/test/actions-runner-assignment-production-branches.test.ts index fdcfd9918..f88a6cd09 100644 --- a/test/actions-runner-assignment-production-branches.test.ts +++ b/test/actions-runner-assignment-production-branches.test.ts @@ -59,6 +59,29 @@ describe("runner-assignment production boundary coverage", () => { }); }); + it("uses the production audit entrypoint and fails closed before GitHub I/O without a token", async () => { + const previousExitCode = process.exitCode; + const previousToken = process.env.GH_TOKEN; + const errors: string[] = []; + const exitCodes: number[] = []; + try { + delete process.env.GH_TOKEN; + const result = await startCli({ + write_error: (value: string) => errors.push(value), + set_exit_code: (code: number) => exitCodes.push(code), + }); + + expect(result).toBeUndefined(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatch(/GH_TOKEN is required for read-only GitHub Actions evidence collection/i); + expect(exitCodes).toEqual([2]); + } finally { + process.exitCode = previousExitCode; + if (previousToken === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previousToken; + } + }); + it("uses the production stderr and exit-code boundaries for an unhandled CLI failure", async () => { const previousExitCode = process.exitCode; try {