diff --git a/scripts/kpi-gate.mjs b/scripts/kpi-gate.mjs index 5fa43b732..12363af16 100644 --- a/scripts/kpi-gate.mjs +++ b/scripts/kpi-gate.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { hasUnsafeSourceId } from "./lib/source-id.mjs"; +import { createKpiChildEnvironment } from "./lib/kpi-child-environment.mjs"; const parsedArgs = parseArgs(process.argv.slice(2)); const logPath = parsedArgs.positionals[0] ?? process.env.NOEMA_KPI_LOG_PATH ?? "exchange-30d.ndjson"; @@ -114,28 +115,13 @@ const guardCommands = [ }, ]; -const kpiChildEnvironment = { ...process.env }; -for (const key of [ - "NODE_OPTIONS", - "NODE_PATH", - "GITHUB_TOKEN", - "GH_TOKEN", - "NVIDIA_NIM_API_KEY", - "COPILOT_GITHUB_TOKEN", -]) { - delete kpiChildEnvironment[key]; -} - let failed = false; const stepSummaries = []; for (const step of guardCommands) { const child = spawnSync(step.command[0], step.command.slice(1), { encoding: "utf8", - env: { - ...kpiChildEnvironment, - ...(step.env ?? {}), - }, + env: createKpiChildEnvironment(step.name, process.env, step.env ?? {}), }); const output = child.stdout || ""; if (output) process.stdout.write(output); diff --git a/scripts/lib/kpi-child-environment.mjs b/scripts/lib/kpi-child-environment.mjs new file mode 100644 index 000000000..51d5dd5e6 --- /dev/null +++ b/scripts/lib/kpi-child-environment.mjs @@ -0,0 +1,36 @@ +const ALERT_KEYS = [ + "NOEMA_ALERT_5M_FAILURE_RATE", + "NOEMA_ALERT_5M_P95_MS", + "NOEMA_ALERT_RATE_LIMIT_MINUTES", + "NOEMA_ALERT_WORKFLOW_SPIKE_MULTIPLIER", +]; + +/** + * Build the complete environment for one KPI child process from an explicit + * allowlist. Ambient parent state is never copied wholesale: the strict KPI + * checker receives only its requested window, while the alert evaluator may + * receive only the four reviewed alert-threshold inputs above. + * + * @param {string} stepName Closed child-step identity. + * @param {Record} parentEnvironment Ambient parent values. + * @param {Record} stepEnvironment Explicit per-step values. + * @returns {Record} Least-authority child environment. + * @throws {Error} When the child-step identity is not part of the closed contract. + */ +export function createKpiChildEnvironment(stepName, parentEnvironment = {}, stepEnvironment = {}) { + if (stepName === "kpi-check") { + const value = stepEnvironment.NOEMA_KPI_REQUIRE_WINDOW_DAYS; + return typeof value === "string" ? { NOEMA_KPI_REQUIRE_WINDOW_DAYS: value } : {}; + } + + if (stepName === "kpi-alert") { + const environment = {}; + for (const key of ALERT_KEYS) { + const value = parentEnvironment[key]; + if (typeof value === "string") environment[key] = value; + } + return environment; + } + + throw new Error(`Unknown KPI child step: ${stepName}`); +} diff --git a/test/kpi-child-environment.test.ts b/test/kpi-child-environment.test.ts new file mode 100644 index 000000000..75ae67f05 --- /dev/null +++ b/test/kpi-child-environment.test.ts @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const hostileParentEnvironment: NodeJS.ProcessEnv = { + PATH: "/synthetic/bin", + HOME: "/synthetic/home", + NODE_OPTIONS: "--require=/synthetic/preload.cjs", + NODE_PATH: "/synthetic/node_modules", + GITHUB_TOKEN: "synthetic-github-token", + GH_TOKEN: "synthetic-gh-token", + COPILOT_GITHUB_TOKEN: "synthetic-copilot-token", + NVIDIA_NIM_API_KEY: "synthetic-nim-key", + NOEMA_LLM_API_KEY: "synthetic-model-key", + NOEMA_MAINTAINER_APP_CLIENT_ID: "synthetic-maintainer-client", + NOEMA_MAINTAINER_APP_PRIVATE_KEY: "synthetic-maintainer-private-key", + NOEMA_REVIEWER_APP_CLIENT_ID: "synthetic-reviewer-client", + NOEMA_REVIEWER_APP_PRIVATE_KEY: "synthetic-reviewer-private-key", + CLOUDFLARE_API_TOKEN: "synthetic-cloudflare-token", + CLOUDFLARE_ACCOUNT_ID: "synthetic-cloudflare-account", + AWS_SECRET_ACCESS_KEY: "synthetic-provider-key", + HTTP_PROXY: "http://synthetic-proxy.invalid", + HTTPS_PROXY: "https://synthetic-proxy.invalid", + ALL_PROXY: "socks5://synthetic-proxy.invalid", + NOEMA_UNRELATED_STATE: "synthetic-unrelated-state", + NOEMA_KPI_REQUIRE_WINDOW_DAYS: "999", + NOEMA_ALERT_5M_FAILURE_RATE: "0.07", + NOEMA_ALERT_5M_P95_MS: "640", + NOEMA_ALERT_RATE_LIMIT_MINUTES: "4", + NOEMA_ALERT_WORKFLOW_SPIKE_MULTIPLIER: "5", +}; + +async function loadEnvironmentFactory() { + const modulePath = "../scripts/lib/kpi-child-environment.mjs"; + return import(modulePath); +} + +describe("KPI child-process least-authority environment", () => { + it("routes every KPI child through the declared environment contract", () => { + const source = readFileSync("scripts/kpi-gate.mjs", "utf8"); + + expect(source).toContain( + 'import { createKpiChildEnvironment } from "./lib/kpi-child-environment.mjs";', + ); + expect(source).not.toContain("const kpiChildEnvironment = { ...process.env }"); + expect(source).toContain("env: createKpiChildEnvironment(step.name, process.env, step.env ?? {}),"); + }); + + it("gives kpi-check only its explicit strict-window input", async () => { + const { createKpiChildEnvironment } = await loadEnvironmentFactory(); + + expect(createKpiChildEnvironment( + "kpi-check", + hostileParentEnvironment, + { NOEMA_KPI_REQUIRE_WINDOW_DAYS: "30" }, + )).toEqual({ + NOEMA_KPI_REQUIRE_WINDOW_DAYS: "30", + }); + }); + + it("gives kpi-alert only the four reviewed alert-threshold inputs", async () => { + const { createKpiChildEnvironment } = await loadEnvironmentFactory(); + + expect(createKpiChildEnvironment("kpi-alert", hostileParentEnvironment, {})).toEqual({ + NOEMA_ALERT_5M_FAILURE_RATE: "0.07", + NOEMA_ALERT_5M_P95_MS: "640", + NOEMA_ALERT_RATE_LIMIT_MINUTES: "4", + NOEMA_ALERT_WORKFLOW_SPIKE_MULTIPLIER: "5", + }); + }); + + it("omits unset allowlisted values instead of emitting undefined child entries", async () => { + const { createKpiChildEnvironment } = await loadEnvironmentFactory(); + + expect(createKpiChildEnvironment( + "kpi-check", + hostileParentEnvironment, + { NOEMA_KPI_REQUIRE_WINDOW_DAYS: undefined }, + )).toEqual({}); + expect(createKpiChildEnvironment( + "kpi-alert", + { + NOEMA_ALERT_5M_FAILURE_RATE: undefined, + NOEMA_ALERT_5M_P95_MS: undefined, + }, + {}, + )).toEqual({}); + }); + + it("fails closed instead of widening authority for an unknown child", async () => { + const { createKpiChildEnvironment } = await loadEnvironmentFactory(); + + expect(() => createKpiChildEnvironment( + "future-kpi-child", + hostileParentEnvironment, + {}, + )).toThrow(/Unknown KPI child step/u); + }); +});