diff --git a/CHANGELOG.md b/CHANGELOG.md index f070de880..22abb3231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- 검증된 active-orphan 워크플로 하나를 운영자가 호출할 수 있는 `operations:workflow-registry-disable` 경로를 추가한다. 저장소와 워크플로 ID를 `NOEMA_MAINTAINER_TOKEN_PATH` 위임 토큰 파일 읽기 전에 검사하고, 신선한 전체 레지스트리 감사·즉시 live refresh·프로세스 로컬 plan·보호된 main/워크플로 재검증·사후 전체 감사 봉투(`schema_version` 1, `PASS`/`FAIL`, `remaining_failure_codes`, `remaining_active_orphan_ids`)를 통과한 뒤에만 영수증을 유지한다. 성공 종료와 `post_audit_status: FAIL`은 해당 ID만 `disabled_manually`가 되었고 레지스트리는 아직 더럽을 수 있음을 뜻하므로, 운영자는 영수증의 `remaining_active_orphan_ids`로 다음 단일 호출을 이어간다. 배치 비활성화·자가 수리 워크플로·거버넌스 완화는 추가하지 않으며 호출 계약은 doctoring에 기록한다. - 읽기 전용 `operations:runner-assignment` audit를 추가해 exact workflow run/source head에 대한 runner assignment를 완전 pagination으로 진단하고, 신선한 unassigned queue는 bounded grace 이후 실패-폐쇄한다. 이 증빙은 runner assignment와 required Check/CI, formal review, merge, release, deployment authority를 분리하며 assigned runner 이후 workflow failure를 성공으로 승격하지 않는다. - coordinated vulnerability disclosure 정책과 evidence-preserving vulnerability handling lifecycle, read-only private-vulnerability-reporting setting audit를 추가한다. 이 source 변경은 live private reporting 활성화·notification staffing·end-to-end advisory exercise·release/deployment authority를 증명하지 않는다. - 개발 의존성 체인의 transitive `nanoid` lockfile resolution을 `3.3.17`에서 `3.3.18`로 최소 갱신하여 GHSA-2v37-7h3g-55p8 / CVE-2026-67213 보안 게이트를 복구한다. PostCSS의 선언 범위 `^3.3.16`과 다른 package metadata는 변경하지 않으며 audit waiver·ignore·severity 완화 없이 `npm ci`/`npm audit --audit-level=high`가 exact head에서 재검증되도록 유지한다. diff --git a/docs/doctoring/workflow-registry-disablement.md b/docs/doctoring/workflow-registry-disablement.md index a694e8cd1..6cfc20ea2 100644 --- a/docs/doctoring/workflow-registry-disablement.md +++ b/docs/doctoring/workflow-registry-disablement.md @@ -40,6 +40,36 @@ The transport is hard-bound to `ContextualWisdomLab/noema`, validates positive i The delegated token is captured only in a closure used to construct the Authorization header. The returned transport object contains functions, not the token value, and diagnostics do not echo response bodies, raw transport exceptions, or credentials. Credential scope and provisioning remain operator responsibilities; this code does not invent, broaden, or fall back to another secret. +### Operator-callable single-candidate disablement + +`scripts/workflow-registry-live-disable.mjs` is the only operator-callable mutation entrypoint. It does not create a repair workflow, does not accept a batch of workflow IDs, and does not disable anything during pull-request CI. + +Invoke one exact audited orphan after provisioning a reviewed, owner-only delegated token file: + +```bash +NOEMA_MAINTAINER_TOKEN_PATH=/secure/noema-maintainer.token \ +GITHUB_REPOSITORY=ContextualWisdomLab/noema \ +npm run operations:workflow-registry-disable -- 101 +``` + +Equivalent direct invocation is `node scripts/workflow-registry-live-disable.mjs 101`. The numeric workflow ID is a required argv. `main()` validates `GITHUB_REPOSITORY` and that workflow ID before `readDelegatedGithubToken()` opens the token file. Ambient `GITHUB_TOKEN`, `COPILOT_GITHUB_TOKEN`, and process-environment secret materialization are refused; the delegated Actions-write capability stays in the reviewed file and a closure-private Authorization header. + +Each invocation collects a full exact-main audit, immediately refreshes the raw registry, builds a process-local plan, revalidates protected `main` plus the exact workflow around one mutation, and then requires a second full audit before it prints a receipt and exits 0. The retained receipt is: + +- `schema_version` (always `1`) +- `repository_full_name` +- `protected_main_sha` +- `workflow_id` +- `workflow_path` +- `prior_state` +- `final_state` +- `mutation` +- `post_audit_status` (`PASS` or `FAIL`) +- `remaining_failure_codes` +- `remaining_active_orphan_ids` + +Exit 0 with `post_audit_status: "FAIL"` means this exact ID is now `disabled_manually` and classified `disabled_registry_record`; the registry may still contain other audited orphans. The next operator action is to take the next ID from `remaining_active_orphan_ids` and invoke this command again. Do not disable from a stale list: every invocation re-audits. Exit 0 with `PASS` and empty residual arrays means this was the last authorizing orphan and the second full audit is clean. A missing schema-v1 envelope, a status other than `PASS`/`FAIL`, a residual `active_orphan_workflow` for the ID just disabled, or a dirty audit after a single-candidate plan refuses the receipt even if GitHub already accepted the `204`. + ### No self-repair workflow Noema intentionally does not create a repository Actions workflow to repair the Actions workflow registry. Such a writer would become another workflow identity that could itself be orphaned, stale, or competing. The bounded transport is an operator primitive consumed by the existing fail-closed plan/executor boundary. @@ -56,6 +86,8 @@ Noema intentionally does not create a repository Actions workflow to repair the | HTTP/API failure | Non-2xx and endpoint-inconsistent 2xx statuses fail closed; disable accepts exactly HTTP 204 | | Malformed successful response | Strict JSON and identity validation fails closed | | Disable acknowledged but not effective | Executor requires a fresh `disabled_manually` postcondition | +| Operator receipt hides residual orphans | Second full audit must be schema-v1 `PASS`/`FAIL`; this ID cannot remain `active_orphan_workflow`; single-candidate plans require `PASS`; residual codes and orphan IDs are printed on the receipt | +| Credential read before authority checks | CLI validates repository and workflow ID before opening `NOEMA_MAINTAINER_TOKEN_PATH` | | Credential disclosure | Closure-private token; response bodies and raw transport exceptions excluded from errors | | API contract drift | Tests pin the current `2026-03-10` version header and documented disable endpoint | | Competing repair writer | No new repository workflow or self-modifying control plane | diff --git a/package.json b/package.json index 6f79d1385..7dbe17f39 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "typecheck": "tsc --noEmit", "governance:audit": "node scripts/main-governance-audit.mjs", "operations:preflight": "node scripts/maintainer-app-readiness.mjs", + "operations:workflow-registry-disable": "node scripts/workflow-registry-live-disable.mjs", "operations:runner-assignment": "node scripts/actions-runner-assignment-audit.mjs", "operations:external-scheduler-evidence": "node scripts/external-scheduler-evidence-audit.mjs", "production:governance": "node scripts/production-environment-governance-audit.mjs", diff --git a/scripts/workflow-registry-disable-plan.mjs b/scripts/workflow-registry-disable-plan.mjs index b48d7cf55..f8f5ff2a7 100644 --- a/scripts/workflow-registry-disable-plan.mjs +++ b/scripts/workflow-registry-disable-plan.mjs @@ -1,3 +1,5 @@ +import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; + const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const WORKFLOW_PATH_PREFIX = ".github/workflows/"; const LOWERCASE_SHA_40 = /^[0-9a-f]{40}$/; @@ -5,6 +7,7 @@ const ISO_UTC_MILLISECOND = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const GITHUB_API_ROOT = "https://api.github.com"; const GITHUB_API_VERSION = "2026-03-10"; const GITHUB_REQUEST_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const AUTHENTIC_PLANS = new WeakSet(); /** @@ -162,6 +165,7 @@ export function createGithubWorkflowDisablementTransport(input) { const headers = Object.freeze({ Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, + "User-Agent": "ContextualWisdomLab-Noema-workflow-registry-operator", "X-GitHub-Api-Version": GITHUB_API_VERSION, }); @@ -235,17 +239,48 @@ export function createGithubWorkflowDisablementTransport(input) { } /** - * Parse a successful GitHub JSON response while hiding invalid remote bytes. + * Parse a successful GitHub JSON response through the same bounded byte-level + * boundary used by the live registry collector. Response size, UTF-8 validity, + * duplicate decoded object keys, and JSON syntax are all fail-closed before + * any remote field can become protected-main or workflow mutation evidence. * * @param {Response} response successful GitHub response * @returns {Promise} parsed JSON value */ async function parseResponseJson(response) { + const advertisedLength = Number(response.headers.get("content-length")); + if (Number.isFinite(advertisedLength) && advertisedLength > MAX_RESPONSE_BYTES) { + throw new Error( + "GitHub workflow disablement transport response exceeds the bounded size limit", + ); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_RESPONSE_BYTES) { + throw new Error( + "GitHub workflow disablement transport response exceeds the bounded size limit", + ); + } + + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("GitHub workflow disablement transport response contains invalid UTF-8"); + } + + let duplicateKeys; try { - return JSON.parse(await response.text()); + duplicateKeys = hasDuplicateJsonObjectKeys(text); } catch { throw new Error("GitHub workflow disablement transport returned invalid JSON"); } + if (duplicateKeys) { + throw new Error( + "GitHub workflow disablement transport response contains duplicate decoded JSON keys", + ); + } + return JSON.parse(text); } /** diff --git a/scripts/workflow-registry-live-disable.mjs b/scripts/workflow-registry-live-disable.mjs new file mode 100644 index 000000000..6448ae885 --- /dev/null +++ b/scripts/workflow-registry-live-disable.mjs @@ -0,0 +1,395 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readDelegatedGithubToken } from "./lib/delegated-github-token.mjs"; +import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs"; +import { + buildWorkflowDisablementPlan, + createGithubWorkflowDisablementTransport, + executeWorkflowDisablement, +} from "./workflow-registry-disable-plan.mjs"; +import { + collectLiveWorkflowRegistryAudit, + workflowPageFromResponse, +} from "./workflow-registry-live-audit.mjs"; + +const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; +const GITHUB_API_ROOT = "https://api.github.com"; +const GITHUB_API_VERSION = "2026-03-10"; +const PER_PAGE = 100; +const MAX_PAGES = 1_000; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +const REQUEST_TIMEOUT_MS = 10_000; + +function validWorkflowId(value) { + return Number.isSafeInteger(value) && value > 0; +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Require a complete schema-v1 post-audit envelope and return residual orphan + * identities that the operator can use for the next single-id invocation. + * A receipt is not honest if this workflow is still an active orphan, if PASS + * and FAIL contradict the residual failure list, or if the only planned + * candidate did not produce a clean registry audit. + * + * @param {object} input authentic plan, requested workflow id, and post-audit + * @returns {{remainingFailureCodes: string[], remainingActiveOrphanIds: number[]}} + */ +function honestPostAuditResiduals(input) { + const postAudit = input?.postAudit; + if (postAudit?.schema_version !== 1) { + throw new Error("full post-disablement audit is not a schema-v1 envelope"); + } + if (postAudit.status !== "PASS" && postAudit.status !== "FAIL") { + throw new Error("full post-disablement audit did not retain an exact PASS or FAIL status"); + } + if (!Array.isArray(postAudit.failures)) { + throw new Error("full post-disablement audit did not retain a complete failure envelope"); + } + + const remainingFailureCodes = []; + const remainingActiveOrphanIds = []; + for (const failure of postAudit.failures) { + if (!isRecord(failure) || typeof failure.code !== "string") { + throw new Error("full post-disablement audit contained a malformed residual failure"); + } + remainingFailureCodes.push(failure.code); + if (failure.code === "active_orphan_workflow") { + if (failure.workflow_id === input.workflowId) { + throw new Error( + "full post-disablement audit still classifies the disabled workflow as an active orphan", + ); + } + if (validWorkflowId(failure.workflow_id)) { + remainingActiveOrphanIds.push(failure.workflow_id); + } + } + } + + if (postAudit.status === "PASS" && remainingFailureCodes.length > 0) { + throw new Error("full post-disablement audit PASS status contradicts residual failures"); + } + if (postAudit.status === "FAIL" && remainingFailureCodes.length === 0) { + throw new Error("full post-disablement audit FAIL status has no residual failures"); + } + if (input.plan.disablements.length === 1 && postAudit.status !== "PASS") { + throw new Error("single-candidate disablement did not produce a clean post-disablement audit"); + } + + return { + remainingFailureCodes, + remainingActiveOrphanIds: [...new Set(remainingActiveOrphanIds)].sort((left, right) => left - right), + }; +} + +function boundedError(error) { + const raw = error instanceof Error ? error.message : String(error); + return raw + .replace(/\bbearer\s+\S+/gi, "Bearer [REDACTED]") + .replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, "[REDACTED]") + .replace(/\bgh[pousr]_[A-Za-z0-9_]+\b/g, "[REDACTED]") + .replace(/[\u0000-\u001f\u007f]/g, "") + .slice(0, 2_048); +} + +/** + * Create a bounded, repository-pinned GitHub JSON reader for the live operator. + * The delegated token is closure-private and never copied into process environment. + * + * @param {{token: string, fetchImpl?: typeof fetch}} input delegated token and fetch primitive + * @returns {(endpoint: string) => Promise} exact-repository JSON reader + */ +export function createWorkflowRegistryGithubJsonReader(input) { + if (typeof input?.token !== "string" || input.token.length === 0) { + throw new Error("workflow registry GitHub reader requires a delegated token"); + } + const fetchImpl = input.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== "function") { + throw new Error("workflow registry GitHub reader requires fetch capability"); + } + const token = input.token; + + return async (endpoint) => { + if (typeof endpoint !== "string" || endpoint.length === 0 || endpoint.includes("\\")) { + throw new Error("workflow registry GitHub endpoint is invalid"); + } + const url = new URL(endpoint, `${GITHUB_API_ROOT}/`); + if ( + url.origin !== GITHUB_API_ROOT + || !url.pathname.startsWith(`/repos/${EXPECTED_REPOSITORY}/`) + || url.username + || url.password + || url.hash + ) { + throw new Error("workflow registry GitHub endpoint escapes the Noema repository boundary"); + } + + let response; + try { + response = await fetchImpl(url, { + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "User-Agent": "ContextualWisdomLab-Noema-workflow-registry-operator", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + }, + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + if (error?.name === "TimeoutError") { + throw new Error("workflow registry GitHub request timed out"); + } + throw new Error("workflow registry GitHub request failed before receiving an HTTP response"); + } + if (!response.ok) { + throw new Error(`workflow registry GitHub request failed with HTTP ${response.status}`); + } + + const advertisedLength = Number(response.headers.get("content-length")); + if (Number.isFinite(advertisedLength) && advertisedLength > MAX_RESPONSE_BYTES) { + throw new Error("workflow registry GitHub response exceeds the bounded size limit"); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_RESPONSE_BYTES) { + throw new Error("workflow registry GitHub response exceeds the bounded size limit"); + } + + let text; + try { + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error("workflow registry GitHub response contains invalid UTF-8"); + } + + let duplicateKeys; + try { + duplicateKeys = hasDuplicateJsonObjectKeys(text); + } catch { + throw new Error("workflow registry GitHub response returned invalid JSON"); + } + if (duplicateKeys) { + throw new Error("workflow registry GitHub response contains duplicate decoded JSON keys"); + } + return JSON.parse(text); + }; +} + +/** + * Re-read the complete workflow registry immediately before building mutation authority. + * Every page must agree on total count and retained records must exactly match that total. + * + * @param {{repository?: string, ghJson: (endpoint: string) => Promise}} input repository and reader + * @returns {Promise} complete fresh raw workflow registry + */ +export async function collectLiveWorkflowRecords(input) { + const repository = input?.repository ?? EXPECTED_REPOSITORY; + if (repository !== EXPECTED_REPOSITORY || typeof input?.ghJson !== "function") { + throw new Error("live workflow registry collection is restricted to ContextualWisdomLab/noema"); + } + + const workflows = []; + let expectedTotal; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const parsed = workflowPageFromResponse( + await input.ghJson(`repos/${repository}/actions/workflows?per_page=${PER_PAGE}&page=${page}`), + page, + PER_PAGE, + ); + if (expectedTotal === undefined) expectedTotal = parsed.totalCount; + if (parsed.totalCount !== expectedTotal) { + throw new Error("workflow registry total changed during immediate pre-mutation refresh"); + } + workflows.push(...parsed.workflows); + if (!parsed.hasNext) { + if (workflows.length !== expectedTotal) { + throw new Error("workflow registry refresh did not retain the advertised record count"); + } + return workflows; + } + } + throw new Error("workflow registry pagination exceeded the bounded page limit"); +} + +/** + * Execute one and only one requested active-orphan workflow disablement. + * The operator collects a full exact-main audit, immediately refreshes raw registry + * identities, builds process-local mutation authority, revalidates main plus workflow + * state around the mutation, and then performs a second full audit before returning a receipt. + * + * @param {object} input exact repository, workflow id, audit/live collectors, and transport + * @returns {Promise} bounded postcondition receipt + */ +export async function runWorkflowRegistryDisablement(input) { + const repository = input?.repository ?? EXPECTED_REPOSITORY; + const workflowId = input?.workflowId; + if (repository !== EXPECTED_REPOSITORY) { + throw new Error(`workflow disablement is restricted to ${EXPECTED_REPOSITORY}`); + } + if (!validWorkflowId(workflowId)) { + throw new Error("requested workflow id must be a positive safe integer"); + } + if (typeof input?.collectAudit !== "function" || typeof input?.collectLiveWorkflows !== "function") { + throw new Error("workflow disablement operator is missing fresh evidence collectors"); + } + const transport = input?.transport; + if ( + typeof transport?.revalidateDefaultBranch !== "function" + || typeof transport?.revalidateWorkflow !== "function" + || typeof transport?.disableWorkflow !== "function" + ) { + throw new Error("workflow disablement operator is missing authorized transport"); + } + + const audit = await input.collectAudit(); + const exactMain = audit?.default_branch_sha; + const liveWorkflows = await input.collectLiveWorkflows(); + const plan = buildWorkflowDisablementPlan({ + audit, + expectedRepository: repository, + expectedDefaultBranchSha: exactMain, + liveWorkflows, + }); + if (plan.status !== "PASS") { + throw new Error(`fresh workflow disablement plan is non-authorizing: ${plan.failures[0].code}`); + } + + const candidate = plan.disablements.find((item) => item.workflow_id === workflowId); + if (!candidate) { + throw new Error("requested workflow is not an exact active-orphan candidate"); + } + + const mutation = await executeWorkflowDisablement({ + plan, + candidate, + revalidateDefaultBranch: transport.revalidateDefaultBranch, + revalidateWorkflow: transport.revalidateWorkflow, + disableWorkflow: transport.disableWorkflow, + }); + + const postAudit = await input.collectAudit(); + if (postAudit?.repository_full_name !== repository) { + throw new Error("repository identity changed during post-disablement verification"); + } + if (postAudit?.default_branch_sha !== plan.default_branch_sha) { + throw new Error("protected main changed during post-disablement verification"); + } + const postWorkflow = Array.isArray(postAudit?.workflows) + ? postAudit.workflows.find((item) => item?.workflow_id === workflowId) + : undefined; + if ( + postWorkflow?.workflow_path !== candidate.workflow_path + || postWorkflow?.workflow_state !== "disabled_manually" + || postWorkflow?.classification !== "disabled_registry_record" + ) { + throw new Error("full post-disablement audit did not retain the exact disabled workflow identity"); + } + + const residuals = honestPostAuditResiduals({ + plan, + workflowId, + postAudit, + }); + + return Object.freeze({ + schema_version: 1, + repository_full_name: repository, + protected_main_sha: plan.default_branch_sha, + workflow_id: mutation.workflow_id, + workflow_path: mutation.workflow_path, + prior_state: mutation.prior_state, + final_state: mutation.final_state, + mutation: mutation.mutation, + post_audit_status: postAudit.status, + remaining_failure_codes: Object.freeze(residuals.remainingFailureCodes), + remaining_active_orphan_ids: Object.freeze(residuals.remainingActiveOrphanIds), + }); +} + +/** + * Operator entrypoint. The target workflow ID is an explicit argument and the + * short-lived delegated GitHub capability comes only from the reviewed token file. + * No batch mode exists: each invocation can mutate at most one exact audited orphan. + * + * @returns {Promise} verified disablement receipt + */ +export async function main() { + const repository = String(process.env.GITHUB_REPOSITORY ?? EXPECTED_REPOSITORY).trim(); + const tokenPath = String(process.env.NOEMA_MAINTAINER_TOKEN_PATH ?? "").trim(); + const workflowId = Number(process.argv[2] ?? ""); + if (repository !== EXPECTED_REPOSITORY) { + throw new Error(`workflow disablement is restricted to ${EXPECTED_REPOSITORY}`); + } + if (!validWorkflowId(workflowId)) { + throw new Error("requested workflow id must be a positive safe integer"); + } + const token = readDelegatedGithubToken(tokenPath); + const ghJson = createWorkflowRegistryGithubJsonReader({ token }); + const transport = createGithubWorkflowDisablementTransport({ + token, + fetchImpl: globalThis.fetch, + }); + const collectAudit = () => collectLiveWorkflowRegistryAudit({ + repository, + defaultBranch: "main", + ghJson, + }); + const receipt = await runWorkflowRegistryDisablement({ + repository, + workflowId, + collectAudit, + collectLiveWorkflows: () => collectLiveWorkflowRecords({ repository, ghJson }), + transport, + }); + console.log(JSON.stringify(receipt, null, 2)); + return receipt; +} + +/** + * Execute the live-disable CLI with isolated error and exit-code boundaries. + * + * @param {{mainFn?: () => Promise, stderr?: (value: unknown) => void, setExitCode?: (code: number) => void}} [options] + * @returns {Promise} operation result or undefined after a bounded failure + */ +export async function startCli({ + mainFn = main, + stderr = console.error, + setExitCode = (code) => { process.exitCode = code; }, +} = {}) { + try { + return await mainFn(); + } catch (error) { + stderr(`workflow-registry-disable failed: ${boundedError(error)}`); + setExitCode(1); + return undefined; + } +} + +/** + * Dispatch the CLI only when this module is the process entrypoint. + * + * @param {{scriptUrl?: string, argv?: string[], pathToFileUrlFn?: (path: string) => {href: string}, starter?: () => unknown}} [options] + * @returns {boolean} whether direct-entry execution was selected + */ +export function runIfDirect({ + scriptUrl = import.meta.url, + argv = process.argv, + pathToFileUrlFn = (value) => pathToFileURL(resolve(value)), + starter = startCli, +} = {}) { + const invokedAsScript = ( + typeof argv[1] === "string" + && argv[1].length > 0 + && pathToFileUrlFn(argv[1]).href === scriptUrl + ); + if (invokedAsScript) void starter(); + return invokedAsScript; +} + +runIfDirect(); diff --git a/test/workflow-registry-disable-plan-json-boundary.test.ts b/test/workflow-registry-disable-plan-json-boundary.test.ts new file mode 100644 index 000000000..f32116c57 --- /dev/null +++ b/test/workflow-registry-disable-plan-json-boundary.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { createGithubWorkflowDisablementTransport } from "../scripts/workflow-registry-disable-plan.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const WORKFLOW_ID = 410; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; + +function transportFor(response: Response) { + return createGithubWorkflowDisablementTransport({ + token: "delegated-token", + fetchImpl: vi.fn(async () => response), + }); +} + +describe("privileged workflow disablement JSON boundary", () => { + it("rejects an advertised Content-Length above the bounded size before reading bytes", async () => { + const arrayBuffer = vi.fn(); + const response = { + ok: true, + status: 200, + headers: { + get(name: string) { + return name.toLowerCase() === "content-length" ? String(MAX_RESPONSE_BYTES + 1) : null; + }, + }, + arrayBuffer, + } as unknown as Response; + + await expect( + transportFor(response).revalidateDefaultBranch({ repository: REPOSITORY }), + ).rejects.toThrow("response exceeds the bounded size limit"); + expect(arrayBuffer).not.toHaveBeenCalled(); + }); + + it("rejects oversized successful GitHub JSON before parsing", async () => { + const response = new Response(`{"padding":"${"x".repeat(MAX_RESPONSE_BYTES)}"}`, { + status: 200, + headers: { "content-type": "application/json" }, + }); + + await expect( + transportFor(response).revalidateDefaultBranch({ repository: REPOSITORY }), + ).rejects.toThrow("response exceeds the bounded size limit"); + }); + + it("rejects malformed UTF-8 instead of accepting replacement-character decoding", async () => { + const response = new Response(new Uint8Array([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + await expect( + transportFor(response).revalidateDefaultBranch({ repository: REPOSITORY }), + ).rejects.toThrow("response contains invalid UTF-8"); + }); + + it("rejects duplicate decoded object keys before last-key-wins JSON parsing", async () => { + const response = new Response( + `{"commit":{"sha":"${MAIN_SHA}"},"comm\\u0069t":{"sha":"${"b".repeat(40)}"}}`, + { status: 200, headers: { "content-type": "application/json" } }, + ); + + await expect( + transportFor(response).revalidateDefaultBranch({ repository: REPOSITORY }), + ).rejects.toThrow("response contains duplicate decoded JSON keys"); + }); + + it("applies the same strict JSON boundary to workflow identity revalidation", async () => { + const response = new Response( + `{"id":${WORKFLOW_ID},"path":".github/workflows/a.yml","state":"active","st\\u0061te":"disabled_manually"}`, + { status: 200, headers: { "content-type": "application/json" } }, + ); + + await expect( + transportFor(response).revalidateWorkflow({ repository: REPOSITORY, workflowId: WORKFLOW_ID }), + ).rejects.toThrow("response contains duplicate decoded JSON keys"); + }); +}); diff --git a/test/workflow-registry-live-disable-branch-coverage.test.ts b/test/workflow-registry-live-disable-branch-coverage.test.ts new file mode 100644 index 000000000..693b18727 --- /dev/null +++ b/test/workflow-registry-live-disable-branch-coverage.test.ts @@ -0,0 +1,272 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + collectLiveWorkflowRecords, + createWorkflowRegistryGithubJsonReader, + main, + runIfDirect, + runWorkflowRegistryDisablement, + startCli, +} from "../scripts/workflow-registry-live-disable.mjs"; + +const ORIGINAL_ENV = { ...process.env }; + +function restoreEnvironment() { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); +} + +afterEach(() => { + restoreEnvironment(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("workflow registry live-disable branch coverage", () => { + it("rejects missing delegated token and fetch capability", () => { + expect(() => createWorkflowRegistryGithubJsonReader({ token: "" })).toThrow( + "requires a delegated token", + ); + vi.stubGlobal("fetch", undefined); + expect(() => createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + })).toThrow("requires fetch capability"); + }); + + it("rejects malformed and escaping GitHub endpoints before network I/O", async () => { + const fetchImpl = vi.fn(); + const ghJson = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + await expect(ghJson("")).rejects.toThrow("endpoint is invalid"); + await expect(ghJson("repos\\ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow( + "endpoint is invalid", + ); + await expect(ghJson("https://example.com/repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("escapes the Noema repository boundary"); + await expect(ghJson("https://user@example.com/repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("escapes the Noema repository boundary"); + await expect(ghJson("https://github.com/repos/ContextualWisdomLab/noema/actions/workflows#fragment")) + .rejects.toThrow("escapes the Noema repository boundary"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("normalizes non-timeout transport rejection without leaking the rejected value", async () => { + const ghJson = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockRejectedValue("remote-secret") as unknown as typeof fetch, + }); + + await expect(ghJson("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("failed before receiving an HTTP response"); + }); + + it("rejects advertised and actual response sizes above the bounded limit", async () => { + const advertised = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => String((8 * 1024 * 1024) + 1) }, + arrayBuffer: vi.fn(), + }) as unknown as typeof fetch, + }); + await expect(advertised("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("bounded size limit"); + + const bytes = new Uint8Array((8 * 1024 * 1024) + 1); + const actual = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => bytes.buffer, + }) as unknown as typeof fetch, + }); + await expect(actual("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("bounded size limit"); + }); + + it("rejects malformed UTF-8 and duplicate decoded JSON keys", async () => { + const malformedUtf8 = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => new Uint8Array([0xc3, 0x28]).buffer, + }) as unknown as typeof fetch, + }); + await expect(malformedUtf8("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("invalid UTF-8"); + + const duplicateBytes = new TextEncoder().encode('{"workflows":[],"\\u0077orkflows":[]}'); + const duplicateKeys = createWorkflowRegistryGithubJsonReader({ + token: "delegated-token", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => duplicateBytes.buffer, + }) as unknown as typeof fetch, + }); + await expect(duplicateKeys("repos/ContextualWisdomLab/noema/actions/workflows")) + .rejects.toThrow("duplicate decoded JSON keys"); + }); + + it("fails closed when live record collection input is missing", async () => { + await expect(collectLiveWorkflowRecords({ + repository: "ContextualWisdomLab/noema", + ghJson: undefined as unknown as (endpoint: string) => Promise, + })).rejects.toThrow("restricted to ContextualWisdomLab/noema"); + }); + + it("rejects missing post-audit workflow evidence after a successful mutation", async () => { + const audit = { + schema_version: 1, + status: "FAIL", + repository_full_name: "ContextualWisdomLab/noema", + default_branch_sha: "a".repeat(40), + observed_at: "2026-08-16T00:00:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + workflows: [{ + workflow_id: 101, + workflow_path: ".github/workflows/orphan.yml", + workflow_state: "active", + classification: "active_orphan", + }], + failures: [{ code: "active_orphan_workflow", workflow_id: 101 }], + }; + const liveWorkflows = [{ id: 101, path: ".github/workflows/orphan.yml", state: "active" }]; + let workflowState = "active"; + let auditCalls = 0; + + await expect(runWorkflowRegistryDisablement({ + repository: "ContextualWisdomLab/noema", + workflowId: 101, + collectAudit: async () => { + auditCalls += 1; + if (auditCalls === 1) return audit; + return { ...audit, workflows: [] }; + }, + collectLiveWorkflows: async () => liveWorkflows, + transport: { + revalidateDefaultBranch: async () => ({ sha: audit.default_branch_sha }), + revalidateWorkflow: async () => ({ + id: 101, + path: ".github/workflows/orphan.yml", + state: workflowState, + }), + disableWorkflow: async () => { + workflowState = "disabled_manually"; + }, + }, + })).rejects.toThrow("full post-disablement audit did not retain the exact disabled workflow identity"); + }); + + it("rejects a malformed post-audit record without substituting identity", async () => { + const audit = { + schema_version: 1, + status: "FAIL", + repository_full_name: "ContextualWisdomLab/noema", + default_branch_sha: "b".repeat(40), + observed_at: "2026-08-16T00:00:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + workflows: [{ + workflow_id: 101, + workflow_path: ".github/workflows/orphan.yml", + workflow_state: "active", + classification: "active_orphan", + }], + failures: [{ code: "active_orphan_workflow", workflow_id: 101 }], + }; + let workflowState = "active"; + let auditCalls = 0; + + await expect(runWorkflowRegistryDisablement({ + repository: "ContextualWisdomLab/noema", + workflowId: 101, + collectAudit: async () => { + auditCalls += 1; + if (auditCalls === 1) return audit; + return { ...audit, workflows: null }; + }, + collectLiveWorkflows: async () => [ + { id: 101, path: ".github/workflows/orphan.yml", state: "active" }, + ], + transport: { + revalidateDefaultBranch: async () => ({ sha: audit.default_branch_sha }), + revalidateWorkflow: async () => ({ + id: 101, + path: ".github/workflows/orphan.yml", + state: workflowState, + }), + disableWorkflow: async () => { + workflowState = "disabled_manually"; + }, + }, + })).rejects.toThrow("full post-disablement audit did not retain the exact disabled workflow identity"); + }); + + it("bounds non-Error CLI failures and redacts secrets", async () => { + const errors: string[] = []; + const exitCodes: number[] = []; + await startCli({ + mainFn: async () => { + throw "Bearer secret\nwith-control"; + }, + stderr: (value: unknown) => errors.push(String(value)), + setExitCode: (value: number) => exitCodes.push(value), + }); + expect(errors).toEqual(["workflow-registry-disable failed: Bearer [REDACTED]with-control"]); + expect(exitCodes).toEqual([1]); + }); + + it("does not dispatch a direct-run check for an empty executable target", () => { + const starter = vi.fn(); + const pathToFileUrlFn = vi.fn(); + expect(runIfDirect({ + scriptUrl: "file:///operator.mjs", + argv: ["node", ""], + pathToFileUrlFn, + starter, + })).toBe(false); + expect(pathToFileUrlFn).not.toHaveBeenCalled(); + expect(starter).not.toHaveBeenCalled(); + }); + + it("fails closed when CLI authority or workflow identity is absent while exercising defaults", async () => { + const originalArgv = process.argv; + const originalRepository = process.env.GITHUB_REPOSITORY; + const originalTokenPath = process.env.NOEMA_MAINTAINER_TOKEN_PATH; + const directory = await mkdtemp(join(tmpdir(), "noema-live-disable-defaults-")); + const tokenPath = join(directory, "github-token"); + try { + await writeFile(tokenPath, "delegated-token", { encoding: "utf8", mode: 0o600 }); + await chmod(tokenPath, 0o600); + delete process.env.GITHUB_REPOSITORY; + process.env.NOEMA_MAINTAINER_TOKEN_PATH = tokenPath; + process.argv = ["node", "workflow-registry-live-disable.mjs"]; + await expect(main()).rejects.toThrow("positive safe integer"); + + delete process.env.NOEMA_MAINTAINER_TOKEN_PATH; + process.argv = ["node", "workflow-registry-live-disable.mjs", "101"]; + await expect(main()).rejects.toThrow("Maintainer token file path is required."); + } finally { + process.argv = originalArgv; + if (originalRepository === undefined) delete process.env.GITHUB_REPOSITORY; + else process.env.GITHUB_REPOSITORY = originalRepository; + if (originalTokenPath === undefined) delete process.env.NOEMA_MAINTAINER_TOKEN_PATH; + else process.env.NOEMA_MAINTAINER_TOKEN_PATH = originalTokenPath; + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/test/workflow-registry-live-disable-cli.test.ts b/test/workflow-registry-live-disable-cli.test.ts new file mode 100644 index 000000000..b2050a20a --- /dev/null +++ b/test/workflow-registry-live-disable-cli.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runIfDirect, + startCli, +} from "../scripts/workflow-registry-live-disable.mjs"; + +describe("workflow registry live-disable CLI boundary", () => { + it("does not start when argv has no executable target", () => { + const starter = vi.fn(); + const invoked = runIfDirect({ + scriptUrl: "file:///operator.mjs", + argv: ["node"], + pathToFileUrlFn: vi.fn(), + starter, + }); + + expect(invoked).toBe(false); + expect(starter).not.toHaveBeenCalled(); + }); + + it("does not start when the resolved invocation URL is a different module", () => { + const starter = vi.fn(); + const pathToFileUrlFn = vi.fn(() => ({ href: "file:///different.mjs" })); + const invoked = runIfDirect({ + scriptUrl: "file:///operator.mjs", + argv: ["node", "/tmp/operator.mjs"], + pathToFileUrlFn, + starter, + }); + + expect(invoked).toBe(false); + expect(pathToFileUrlFn).toHaveBeenCalledWith("/tmp/operator.mjs"); + expect(starter).not.toHaveBeenCalled(); + }); + + it("starts exactly once when the resolved invocation URL matches", () => { + const starter = vi.fn(); + const invoked = runIfDirect({ + scriptUrl: "file:///operator.mjs", + argv: ["node", "/tmp/operator.mjs"], + pathToFileUrlFn: vi.fn(() => ({ href: "file:///operator.mjs" })), + starter, + }); + + expect(invoked).toBe(true); + expect(starter).toHaveBeenCalledTimes(1); + }); + + it("uses the process exit-code default when a CLI failure omits setExitCode", async () => { + const previousExitCode = process.exitCode; + const stderr = vi.fn(); + try { + process.exitCode = undefined; + await startCli({ + mainFn: async () => { + throw new Error("bounded operator failure"); + }, + stderr, + }); + expect(process.exitCode).toBe(1); + expect(stderr).toHaveBeenCalledTimes(1); + expect(String(stderr.mock.calls[0]?.[0] ?? "")).toContain("workflow-registry-disable failed:"); + } finally { + process.exitCode = previousExitCode; + } + }); + + it("leaves exit state untouched after a successful CLI operation", async () => { + const stderr = vi.fn(); + const setExitCode = vi.fn(); + const mainFn = vi.fn(async () => ({ status: "PASS" })); + + await startCli({ mainFn, stderr, setExitCode }); + + expect(mainFn).toHaveBeenCalledTimes(1); + expect(stderr).not.toHaveBeenCalled(); + expect(setExitCode).not.toHaveBeenCalled(); + }); + + it("bounds and redacts a failed CLI operation before setting exit code", async () => { + const stderr = vi.fn(); + const setExitCode = vi.fn(); + const mainFn = vi.fn(async () => { + throw new Error(`delegated token ghp_${"A".repeat(80)} was rejected`); + }); + + await startCli({ mainFn, stderr, setExitCode }); + + expect(setExitCode).toHaveBeenCalledTimes(1); + expect(setExitCode).toHaveBeenCalledWith(1); + expect(stderr).toHaveBeenCalledTimes(1); + const emitted = String(stderr.mock.calls[0]?.[0] ?? ""); + expect(emitted).toContain("workflow-registry-disable failed:"); + expect(emitted).toContain("[REDACTED]"); + expect(emitted).not.toContain("ghp_"); + }); + + it("redacts a fine-grained GitHub PAT from failed CLI diagnostics", async () => { + const stderr = vi.fn(); + const setExitCode = vi.fn(); + const fineGrainedPat = `github_pat_${"B".repeat(82)}`; + const mainFn = vi.fn(async () => { + throw new Error(`delegated token ${fineGrainedPat} was rejected`); + }); + + await startCli({ mainFn, stderr, setExitCode }); + + expect(setExitCode).toHaveBeenCalledWith(1); + expect(stderr).toHaveBeenCalledTimes(1); + const emitted = String(stderr.mock.calls[0]?.[0] ?? ""); + expect(emitted).toContain("[REDACTED]"); + expect(emitted).not.toContain("github_pat_"); + expect(emitted).not.toContain(fineGrainedPat); + }); +}); diff --git a/test/workflow-registry-live-disable-coverage.test.ts b/test/workflow-registry-live-disable-coverage.test.ts new file mode 100644 index 000000000..d5bf9c6af --- /dev/null +++ b/test/workflow-registry-live-disable-coverage.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import { + collectLiveWorkflowRecords, + createWorkflowRegistryGithubJsonReader, + runWorkflowRegistryDisablement, +} from "../scripts/workflow-registry-live-disable.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const ORPHAN_PATH = ".github/workflows/obsolete-repair.yml"; +const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; + +function fakeResponse({ ok = true, status = 200, headers = {}, body = "{}" }: { ok?: boolean; status?: number; headers?: Record; body?: string | Uint8Array } = {}) { + const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body; + return { ok, status, headers: { get(name: string) { return headers[name.toLowerCase()] ?? null; } }, async arrayBuffer() { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); } }; +} + +function activeAudit() { + return { schema_version: 1, repository_full_name: REPOSITORY, default_branch_sha: MAIN_SHA, observed_at: "2026-08-16T03:10:00.000Z", pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], status: "FAIL", failures: [{ code: "active_orphan_workflow", workflow_id: 101, detail: "Active workflow is absent from protected main." }], workflows: [{ workflow_id: 101, workflow_path: ORPHAN_PATH, workflow_state: "active", classification: "active_orphan" }] }; +} + +function disabledAudit(overrides: Record = {}) { + return { schema_version: 1, repository_full_name: REPOSITORY, default_branch_sha: MAIN_SHA, observed_at: "2026-08-16T03:10:01.000Z", pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], status: "PASS", failures: [], workflows: [{ workflow_id: 101, workflow_path: ORPHAN_PATH, workflow_state: "disabled_manually", classification: "disabled_registry_record" }], ...overrides }; +} + +function validTransport() { + return { revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), revalidateWorkflow: vi.fn().mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "active" }).mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "disabled_manually" }), disableWorkflow: vi.fn().mockResolvedValue(undefined) }; +} + +describe("workflow registry bounded GitHub reader", () => { + it("rejects missing credentials and fetch capability before making a request", () => { + expect(() => createWorkflowRegistryGithubJsonReader({ token: "" })).toThrow("requires a delegated token"); + expect(() => createWorkflowRegistryGithubJsonReader({ token: "delegated", fetchImpl: 0 as never })).toThrow("requires fetch capability"); + }); + + it("pins reads to safe Noema repository endpoints", async () => { + const fetchImpl = vi.fn(); + const reader = createWorkflowRegistryGithubJsonReader({ token: "delegated", fetchImpl }); + await expect(reader("")).rejects.toThrow("endpoint is invalid"); + await expect(reader("repos\\ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow("endpoint is invalid"); + await expect(reader("https://example.com/repos/ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow("escapes the Noema repository boundary"); + await expect(reader("repos/ContextualWisdomLab/other/actions/workflows")).rejects.toThrow("escapes the Noema repository boundary"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("returns validated JSON and sends the delegated token only in the request header", async () => { + const fetchImpl = vi.fn().mockResolvedValue(fakeResponse({ body: '{"total_count":0,"workflows":[]}' })); + const reader = createWorkflowRegistryGithubJsonReader({ token: "delegated-token", fetchImpl }); + await expect(reader("repos/ContextualWisdomLab/noema/actions/workflows?per_page=100&page=1")).resolves.toEqual({ total_count: 0, workflows: [] }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, options] = fetchImpl.mock.calls[0]; + expect(String(url)).toBe("https://api.github.com/repos/ContextualWisdomLab/noema/actions/workflows?per_page=100&page=1"); + expect(options.method).toBe("GET"); + expect(options.redirect).toBe("error"); + expect(options.cache).toBe("no-store"); + expect(options.headers.Authorization).toBe("Bearer delegated-token"); + }); + + it("maps timeout and network failures to bounded non-secret diagnostics", async () => { + const timeout = Object.assign(new Error("Bearer delegated-token"), { name: "TimeoutError" }); + const timeoutReader = createWorkflowRegistryGithubJsonReader({ token: "delegated-token", fetchImpl: vi.fn().mockRejectedValue(timeout) }); + await expect(timeoutReader("repos/ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow("request timed out"); + const networkReader = createWorkflowRegistryGithubJsonReader({ token: "delegated-token", fetchImpl: vi.fn().mockRejectedValue(new Error("ghp_secret")) }); + await expect(networkReader("repos/ContextualWisdomLab/noema/actions/workflows")).rejects.toThrow("failed before receiving an HTTP response"); + }); + + it("fails closed on non-success, oversized, malformed UTF-8, duplicate-key, and invalid JSON bodies", async () => { + const endpoint = "repos/ContextualWisdomLab/noema/actions/workflows"; + const cases: Array<[ReturnType, string]> = [ + [fakeResponse({ ok: false, status: 503 }), "failed with HTTP 503"], + [fakeResponse({ headers: { "content-length": String(MAX_RESPONSE_BYTES + 1) } }), "exceeds the bounded size limit"], + [fakeResponse({ body: new Uint8Array(MAX_RESPONSE_BYTES + 1) }), "exceeds the bounded size limit"], + [fakeResponse({ body: new Uint8Array([0xff]) }), "contains invalid UTF-8"], + [fakeResponse({ body: '{"workflow":1,"workflow":2}' }), "duplicate decoded JSON keys"], + [fakeResponse({ body: "{" }), "returned invalid JSON"], + ]; + for (const [response, message] of cases) { + const reader = createWorkflowRegistryGithubJsonReader({ token: "delegated", fetchImpl: vi.fn().mockResolvedValue(response) }); + await expect(reader(endpoint)).rejects.toThrow(message); + } + }); +}); + +describe("immediate full workflow registry refresh", () => { + it("rejects any repository or reader outside the exact Noema authority", async () => { + await expect(collectLiveWorkflowRecords({ repository: "ContextualWisdomLab/other", ghJson: vi.fn() })).rejects.toThrow("restricted to ContextualWisdomLab/noema"); + await expect(collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson: 0 as never })).rejects.toThrow("restricted to ContextualWisdomLab/noema"); + }); + + it("defaults collection to the pinned Noema repository when the caller omits it", async () => { + const ghJson = vi.fn().mockResolvedValue({ total_count: 0, workflows: [] }); + await expect(collectLiveWorkflowRecords({ ghJson })).resolves.toEqual([]); + expect(ghJson).toHaveBeenCalledWith("repos/ContextualWisdomLab/noema/actions/workflows?per_page=100&page=1"); + }); + + it("collects every paginated registry record and preserves page order", async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, path: `.github/workflows/workflow-${index + 1}.yml`, state: "active" })); + const finalWorkflow = { id: 101, path: ".github/workflows/workflow-101.yml", state: "disabled_manually" }; + const ghJson = vi.fn().mockResolvedValueOnce({ total_count: 101, workflows: firstPage }).mockResolvedValueOnce({ total_count: 101, workflows: [finalWorkflow] }); + const workflows = await collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson }); + expect(workflows).toHaveLength(101); + expect(workflows[0]).toEqual(firstPage[0]); + expect(workflows[100]).toEqual(finalWorkflow); + expect(ghJson).toHaveBeenCalledTimes(2); + }); + + it("fails closed when total count changes or a terminal page does not retain that count", async () => { + const changedTotal = vi.fn().mockResolvedValueOnce({ total_count: 101, workflows: [] }).mockResolvedValueOnce({ total_count: 100, workflows: [] }); + await expect(collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson: changedTotal })).rejects.toThrow("total changed during immediate pre-mutation refresh"); + await expect(collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson: vi.fn().mockResolvedValue({ total_count: 2, workflows: [{ id: 1 }] }) })).rejects.toThrow("did not retain the advertised record count"); + }); + + it("bounds pathological pagination even when the remote count never terminates", async () => { + const ghJson = vi.fn().mockResolvedValue({ total_count: 100_001, workflows: [] }); + await expect(collectLiveWorkflowRecords({ repository: REPOSITORY, ghJson })).rejects.toThrow("pagination exceeded the bounded page limit"); + expect(ghJson).toHaveBeenCalledTimes(1_000); + }); +}); + +describe("disablement input and postcondition boundaries", () => { + it("rejects invalid repository, workflow identity, collectors, and transport", async () => { + await expect(runWorkflowRegistryDisablement({ repository: "other", workflowId: 101 })).rejects.toThrow("restricted to ContextualWisdomLab/noema"); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 0 })).rejects.toThrow("positive safe integer"); + await expect(runWorkflowRegistryDisablement({ workflowId: 101 })).rejects.toThrow("missing fresh evidence collectors"); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101 })).rejects.toThrow("missing fresh evidence collectors"); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101, collectAudit: vi.fn(), collectLiveWorkflows: vi.fn(), transport: {} })).rejects.toThrow("missing authorized transport"); + }); + + it("does not mutate when fresh planning is non-authorizing", async () => { + const transport = validTransport(); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101, collectAudit: vi.fn().mockResolvedValue(activeAudit()), collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }, { id: 101, path: ".github/workflows/reused.yml", state: "active" }]), transport })).rejects.toThrow("fresh workflow disablement plan is non-authorizing"); + expect(transport.disableWorkflow).not.toHaveBeenCalled(); + }); + + it("rejects a post-audit repository substitution", async () => { + const collectAudit = vi.fn().mockResolvedValueOnce(activeAudit()).mockResolvedValueOnce(disabledAudit({ repository_full_name: "ContextualWisdomLab/other" })); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101, collectAudit, collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), transport: validTransport() })).rejects.toThrow("repository identity changed during post-disablement verification"); + }); + + it("rejects a post-audit that loses or changes the exact disabled workflow identity", async () => { + const invalidPostStates = [null, [], [{ workflow_id: 101, workflow_path: ".github/workflows/different.yml", workflow_state: "disabled_manually", classification: "disabled_registry_record" }], [{ workflow_id: 101, workflow_path: ORPHAN_PATH, workflow_state: "active", classification: "active_orphan" }]]; + for (const workflows of invalidPostStates) { + const collectAudit = vi.fn().mockResolvedValueOnce(activeAudit()).mockResolvedValueOnce(disabledAudit({ workflows })); + await expect(runWorkflowRegistryDisablement({ repository: REPOSITORY, workflowId: 101, collectAudit, collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), transport: validTransport() })).rejects.toThrow("did not retain the exact disabled workflow identity"); + } + }); +}); diff --git a/test/workflow-registry-live-disable-docs.test.ts b/test/workflow-registry-live-disable-docs.test.ts new file mode 100644 index 000000000..3572eb8e3 --- /dev/null +++ b/test/workflow-registry-live-disable-docs.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function readText(path: string): string { + return readFileSync(path, "utf8"); +} + +describe("workflow-registry live-disable operator documentation contract", () => { + it("keeps the operator command, doctoring, and changelog aligned", () => { + const packageJson = JSON.parse(readText("package.json")); + const doctoring = readText("docs/doctoring/workflow-registry-disablement.md"); + const changelog = readText("CHANGELOG.md"); + + expect(packageJson.scripts["operations:workflow-registry-disable"]).toBe( + "node scripts/workflow-registry-live-disable.mjs", + ); + + for (const phrase of [ + "scripts/workflow-registry-live-disable.mjs", + "npm run operations:workflow-registry-disable -- 101", + "NOEMA_MAINTAINER_TOKEN_PATH", + "ContextualWisdomLab/noema", + "remaining_failure_codes", + "remaining_active_orphan_ids", + "post_audit_status", + "disabled_manually", + "disabled_registry_record", + "COPILOT_GITHUB_TOKEN", + "schema-v1", + ]) { + expect(doctoring).toContain(phrase); + } + + expect(changelog).toContain("`operations:workflow-registry-disable`"); + expect(changelog).toContain("NOEMA_MAINTAINER_TOKEN_PATH"); + expect(changelog).toContain("remaining_active_orphan_ids"); + expect(changelog).toContain("post_audit_status: FAIL"); + }); +}); diff --git a/test/workflow-registry-live-disable-main.test.ts b/test/workflow-registry-live-disable-main.test.ts new file mode 100644 index 000000000..4c5730386 --- /dev/null +++ b/test/workflow-registry-live-disable-main.test.ts @@ -0,0 +1,110 @@ +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { main } from "../scripts/workflow-registry-live-disable.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const WORKFLOW_ID = 101; +const ORPHAN_PATH = ".github/workflows/obsolete-repair.yml"; + +function githubResponse(body: unknown, status = 200) { + const text = status === 204 ? "" : JSON.stringify(body); + const bytes = new TextEncoder().encode(text); + return { + ok: status >= 200 && status < 300, + status, + headers: { + get(name: string) { + return name.toLowerCase() === "content-length" ? String(bytes.byteLength) : null; + }, + }, + async arrayBuffer() { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + }, + async text() { + return text; + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("workflow registry live-disable executable main", () => { + it("uses an owner-only delegated capability to disable one audited orphan and retain a full post-audit receipt", async () => { + const directory = await mkdtemp(join(tmpdir(), "noema-workflow-disable-")); + const tokenPath = join(directory, "github-token"); + const originalArgv = process.argv; + let workflowState = "active"; + + try { + await writeFile(tokenPath, "delegated-token", { encoding: "utf8", mode: 0o600 }); + await chmod(tokenPath, 0o600); + vi.stubEnv("GITHUB_REPOSITORY", REPOSITORY); + vi.stubEnv("NOEMA_MAINTAINER_TOKEN_PATH", tokenPath); + process.argv = ["node", "workflow-registry-live-disable.mjs", String(WORKFLOW_ID)]; + + const fetchImpl = vi.fn(async (input: URL | string, options: { method?: string } = {}) => { + const url = String(input); + const method = options.method ?? "GET"; + + if (url.endsWith(`/repos/${REPOSITORY}/branches/main`) && method === "GET") { + return githubResponse({ commit: { sha: MAIN_SHA } }); + } + if (url.includes(`/repos/${REPOSITORY}/git/trees/${MAIN_SHA}?recursive=1`) && method === "GET") { + return githubResponse({ + truncated: false, + tree: [{ type: "blob", path: ".github/workflows/ci.yml" }], + }); + } + if (url.includes(`/repos/${REPOSITORY}/actions/workflows?`) && method === "GET") { + return githubResponse({ + total_count: 1, + workflows: [{ id: WORKFLOW_ID, path: ORPHAN_PATH, state: workflowState }], + }); + } + if (url.includes(`/repos/${REPOSITORY}/pulls?state=open`) && method === "GET") { + return githubResponse([]); + } + if (url.endsWith(`/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_ID}`) && method === "GET") { + return githubResponse({ id: WORKFLOW_ID, path: ORPHAN_PATH, state: workflowState }); + } + if (url.endsWith(`/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_ID}/disable`) && method === "PUT") { + workflowState = "disabled_manually"; + return githubResponse(undefined, 204); + } + throw new Error(`unexpected GitHub request: ${method} ${url}`); + }); + vi.stubGlobal("fetch", fetchImpl); + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const receipt = await main(); + + expect(receipt).toEqual({ + schema_version: 1, + repository_full_name: REPOSITORY, + protected_main_sha: MAIN_SHA, + workflow_id: WORKFLOW_ID, + workflow_path: ORPHAN_PATH, + prior_state: "active", + final_state: "disabled_manually", + mutation: "disable", + post_audit_status: "PASS", + remaining_failure_codes: [], + remaining_active_orphan_ids: [], + }); + expect(workflowState).toBe("disabled_manually"); + expect(fetchImpl.mock.calls.filter(([, options]) => options?.method === "PUT")).toHaveLength(1); + expect(consoleLog).toHaveBeenCalledTimes(1); + expect(String(consoleLog.mock.calls[0]?.[0] ?? "")).toContain('"final_state": "disabled_manually"'); + } finally { + process.argv = originalArgv; + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/test/workflow-registry-live-disable-operator.test.ts b/test/workflow-registry-live-disable-operator.test.ts new file mode 100644 index 000000000..b93502c6d --- /dev/null +++ b/test/workflow-registry-live-disable-operator.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from "vitest"; +import { runWorkflowRegistryDisablement } from "../scripts/workflow-registry-live-disable.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const OBSERVED_AT = "2026-08-16T03:10:00.000Z"; + +function activeAudit() { + return { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: MAIN_SHA, + observed_at: OBSERVED_AT, + pagination_receipts: [{ page: 1, itemCount: 2, hasNext: false }], + status: "FAIL", + failures: [{ + code: "active_orphan_workflow", + workflow_id: 101, + detail: "Active workflow is absent from protected main.", + }], + workflows: [ + { + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + workflow_state: "active", + classification: "active_orphan", + }, + { + workflow_id: 202, + workflow_path: ".github/workflows/ci.yml", + workflow_state: "active", + classification: "present_on_default_branch", + }, + ], + }; +} + +function postAudit(defaultBranchSha = MAIN_SHA) { + return { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: defaultBranchSha, + observed_at: "2026-08-16T03:10:01.000Z", + pagination_receipts: [{ page: 1, itemCount: 2, hasNext: false }], + status: "PASS", + failures: [], + workflows: [ + { + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + workflow_state: "disabled_manually", + classification: "disabled_registry_record", + }, + { + workflow_id: 202, + workflow_path: ".github/workflows/ci.yml", + workflow_state: "active", + classification: "present_on_default_branch", + }, + ], + }; +} + +const liveWorkflows = [ + { id: 101, path: ".github/workflows/obsolete-repair.yml", state: "active" }, + { id: 202, path: ".github/workflows/ci.yml", state: "active" }, +]; + +describe("live workflow-registry disablement operator", () => { + it("disables exactly the requested audited orphan and verifies the full post-state", async () => { + const collectAudit = vi + .fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce(postAudit()); + const disableWorkflow = vi.fn().mockResolvedValue(undefined); + const revalidateWorkflow = vi + .fn() + .mockResolvedValueOnce(liveWorkflows[0]) + .mockResolvedValueOnce({ ...liveWorkflows[0], state: "disabled_manually" }); + + const receipt = await runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit, + collectLiveWorkflows: vi.fn().mockResolvedValue(liveWorkflows), + transport: { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow, + disableWorkflow, + }, + }); + + expect(disableWorkflow).toHaveBeenCalledTimes(1); + expect(disableWorkflow).toHaveBeenCalledWith({ repository: REPOSITORY, workflowId: 101 }); + expect(collectAudit).toHaveBeenCalledTimes(2); + expect(receipt).toEqual({ + schema_version: 1, + repository_full_name: REPOSITORY, + protected_main_sha: MAIN_SHA, + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + prior_state: "active", + final_state: "disabled_manually", + mutation: "disable", + post_audit_status: "PASS", + remaining_failure_codes: [], + remaining_active_orphan_ids: [], + }); + }); + + it("disables only the requested orphan when the fresh plan contains multiple audited orphans", async () => { + const twoOrphanAudit = { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: MAIN_SHA, + observed_at: OBSERVED_AT, + pagination_receipts: [{ page: 1, itemCount: 4, hasNext: false }], + status: "FAIL", + failures: [ + { + code: "active_orphan_workflow", + workflow_id: 101, + detail: "Active workflow is absent from protected main.", + }, + { + code: "active_orphan_workflow", + workflow_id: 303, + detail: "Active workflow is absent from protected main.", + }, + { + code: "active_orphan_workflow", + workflow_id: 404, + detail: "Active workflow is absent from protected main.", + }, + ], + workflows: [ + { + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + workflow_state: "active", + classification: "active_orphan", + }, + { + workflow_id: 202, + workflow_path: ".github/workflows/ci.yml", + workflow_state: "active", + classification: "present_on_default_branch", + }, + { + workflow_id: 303, + workflow_path: ".github/workflows/one-shot-repair.yml", + workflow_state: "active", + classification: "active_orphan", + }, + { + workflow_id: 404, + workflow_path: ".github/workflows/apply-final-candidate-cleanup.yml", + workflow_state: "active", + classification: "active_orphan", + }, + ], + }; + const twoOrphanLive = [ + { id: 101, path: ".github/workflows/obsolete-repair.yml", state: "active" }, + { id: 202, path: ".github/workflows/ci.yml", state: "active" }, + { id: 303, path: ".github/workflows/one-shot-repair.yml", state: "active" }, + { id: 404, path: ".github/workflows/apply-final-candidate-cleanup.yml", state: "active" }, + ]; + const residualPostAudit = { + ...twoOrphanAudit, + observed_at: "2026-08-16T03:10:01.000Z", + pagination_receipts: [{ page: 1, itemCount: 4, hasNext: false }], + failures: [ + { + code: "active_orphan_workflow", + workflow_id: 404, + detail: "Active workflow is absent from protected main.", + }, + { + code: "active_orphan_workflow", + workflow_id: 0, + detail: "Malformed residual orphan identity is ignored for the next invocation list.", + }, + { + code: "active_orphan_workflow", + workflow_id: 303, + detail: "Active workflow is absent from protected main.", + }, + ], + workflows: [ + { + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + workflow_state: "disabled_manually", + classification: "disabled_registry_record", + }, + twoOrphanAudit.workflows[1], + twoOrphanAudit.workflows[2], + twoOrphanAudit.workflows[3], + ], + }; + const disableWorkflow = vi.fn().mockResolvedValue(undefined); + + const receipt = await runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(twoOrphanAudit) + .mockResolvedValueOnce(residualPostAudit), + collectLiveWorkflows: vi.fn().mockResolvedValue(twoOrphanLive), + transport: { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow: vi + .fn() + .mockResolvedValueOnce(twoOrphanLive[0]) + .mockResolvedValueOnce({ ...twoOrphanLive[0], state: "disabled_manually" }), + disableWorkflow, + }, + }); + + expect(disableWorkflow).toHaveBeenCalledTimes(1); + expect(disableWorkflow).toHaveBeenCalledWith({ repository: REPOSITORY, workflowId: 101 }); + expect(receipt).toEqual({ + schema_version: 1, + repository_full_name: REPOSITORY, + protected_main_sha: MAIN_SHA, + workflow_id: 101, + workflow_path: ".github/workflows/obsolete-repair.yml", + prior_state: "active", + final_state: "disabled_manually", + mutation: "disable", + post_audit_status: "FAIL", + remaining_failure_codes: ["active_orphan_workflow", "active_orphan_workflow", "active_orphan_workflow"], + remaining_active_orphan_ids: [303, 404], + }); + }); + + it("refuses a workflow identity that is not an exact audited active orphan", async () => { + const disableWorkflow = vi.fn(); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 202, + collectAudit: vi.fn().mockResolvedValue(activeAudit()), + collectLiveWorkflows: vi.fn().mockResolvedValue(liveWorkflows), + transport: { + revalidateDefaultBranch: vi.fn(), + revalidateWorkflow: vi.fn(), + disableWorkflow, + }, + })).rejects.toThrow("requested workflow is not an exact active-orphan candidate"); + + expect(disableWorkflow).not.toHaveBeenCalled(); + }); + + it("fails the retained receipt if protected main moves during post-disablement verification", async () => { + const collectAudit = vi + .fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce(postAudit("b".repeat(40))); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit, + collectLiveWorkflows: vi.fn().mockResolvedValue(liveWorkflows), + transport: { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow: vi + .fn() + .mockResolvedValueOnce(liveWorkflows[0]) + .mockResolvedValueOnce({ ...liveWorkflows[0], state: "disabled_manually" }), + disableWorkflow: vi.fn().mockResolvedValue(undefined), + }, + })).rejects.toThrow("protected main changed during post-disablement verification"); + }); +}); diff --git a/test/workflow-registry-live-disable-residual-coverage.test.ts b/test/workflow-registry-live-disable-residual-coverage.test.ts new file mode 100644 index 000000000..5336ef8d5 --- /dev/null +++ b/test/workflow-registry-live-disable-residual-coverage.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from "vitest"; +import { runWorkflowRegistryDisablement } from "../scripts/workflow-registry-live-disable.mjs"; + +const REPOSITORY = "ContextualWisdomLab/noema"; +const MAIN_SHA = "a".repeat(40); +const ORPHAN_PATH = ".github/workflows/obsolete-repair.yml"; + +function validTransport() { + return { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow: vi.fn(), + disableWorkflow: vi.fn(), + }; +} + +function activeAudit() { + return { + schema_version: 1, + repository_full_name: REPOSITORY, + default_branch_sha: MAIN_SHA, + observed_at: "2026-08-16T06:10:00.000Z", + pagination_receipts: [{ page: 1, itemCount: 1, hasNext: false }], + status: "FAIL", + failures: [{ code: "active_orphan_workflow", workflow_id: 101 }], + workflows: [{ + workflow_id: 101, + workflow_path: ORPHAN_PATH, + workflow_state: "active", + classification: "active_orphan", + }], + }; +} + +describe("workflow live-disable residual optional-evidence boundaries", () => { + it("rejects an absent transport after both fresh collectors are present", async () => { + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn(), + collectLiveWorkflows: vi.fn(), + })).rejects.toThrow("missing authorized transport"); + }); + + it("treats an absent pre-mutation audit as non-authorizing evidence", async () => { + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn().mockResolvedValue(undefined), + collectLiveWorkflows: vi.fn().mockResolvedValue([]), + transport: validTransport(), + })).rejects.toThrow("fresh workflow disablement plan is non-authorizing: repository_identity_invalid"); + }); + + it("rejects a post-audit that retains repository identity but loses protected-main identity", async () => { + const transport = { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow: vi + .fn() + .mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "active" }) + .mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "disabled_manually" }), + disableWorkflow: vi.fn().mockResolvedValue(undefined), + }; + const collectAudit = vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ repository_full_name: REPOSITORY }); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit, + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport, + })).rejects.toThrow("protected main changed during post-disablement verification"); + }); + + function successfulMutationTransport() { + return { + revalidateDefaultBranch: vi.fn().mockResolvedValue({ sha: MAIN_SHA }), + revalidateWorkflow: vi + .fn() + .mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "active" }) + .mockResolvedValueOnce({ id: 101, path: ORPHAN_PATH, state: "disabled_manually" }), + disableWorkflow: vi.fn().mockResolvedValue(undefined), + }; + } + + it("refuses a post-audit that lacks a schema-v1 PASS/FAIL envelope after identity checks", async () => { + const disabledIdentity = { + workflow_id: 101, + workflow_path: ORPHAN_PATH, + workflow_state: "disabled_manually", + classification: "disabled_registry_record", + }; + const honestBase = { + ...activeAudit(), + workflows: [disabledIdentity], + }; + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ + ...honestBase, + schema_version: 2, + status: "PASS", + failures: [], + }), + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport: successfulMutationTransport(), + })).rejects.toThrow("full post-disablement audit is not a schema-v1 envelope"); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ + ...honestBase, + status: "UNKNOWN", + failures: [], + }), + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport: successfulMutationTransport(), + })).rejects.toThrow("exact PASS or FAIL status"); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ + ...honestBase, + status: "PASS", + failures: null, + }), + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport: successfulMutationTransport(), + })).rejects.toThrow("complete failure envelope"); + }); + + it("refuses a single-candidate receipt when the disabled workflow remains an active orphan or the audit stays dirty", async () => { + const disabledIdentity = { + workflow_id: 101, + workflow_path: ORPHAN_PATH, + workflow_state: "disabled_manually", + classification: "disabled_registry_record", + }; + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ + ...activeAudit(), + workflows: [disabledIdentity], + failures: [{ code: "active_orphan_workflow", workflow_id: 101 }], + }), + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport: successfulMutationTransport(), + })).rejects.toThrow("still classifies the disabled workflow as an active orphan"); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ + ...activeAudit(), + workflows: [disabledIdentity], + status: "FAIL", + failures: [{ code: "active_orphan_workflow", workflow_id: 202 }], + }), + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport: successfulMutationTransport(), + })).rejects.toThrow("single-candidate disablement did not produce a clean post-disablement audit"); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ + ...activeAudit(), + workflows: [disabledIdentity], + status: "PASS", + failures: [{ code: "unexpected_residual", workflow_id: 202 }], + }), + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport: successfulMutationTransport(), + })).rejects.toThrow("PASS status contradicts residual failures"); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ + ...activeAudit(), + workflows: [disabledIdentity], + status: "FAIL", + failures: [], + }), + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport: successfulMutationTransport(), + })).rejects.toThrow("FAIL status has no residual failures"); + + await expect(runWorkflowRegistryDisablement({ + repository: REPOSITORY, + workflowId: 101, + collectAudit: vi.fn() + .mockResolvedValueOnce(activeAudit()) + .mockResolvedValueOnce({ + ...activeAudit(), + workflows: [disabledIdentity], + status: "PASS", + failures: [null], + }), + collectLiveWorkflows: vi.fn().mockResolvedValue([{ id: 101, path: ORPHAN_PATH, state: "active" }]), + transport: successfulMutationTransport(), + })).rejects.toThrow("malformed residual failure"); + }); +}); diff --git a/test/workflow-registry-live-disable-secret-order.test.ts b/test/workflow-registry-live-disable-secret-order.test.ts new file mode 100644 index 000000000..f3d158c4e --- /dev/null +++ b/test/workflow-registry-live-disable-secret-order.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { main } from "../scripts/workflow-registry-live-disable.mjs"; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_ARGV = [...process.argv]; + +function restoreProcessState() { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); + process.argv = [...ORIGINAL_ARGV]; +} + +afterEach(() => { + restoreProcessState(); +}); + +describe("workflow registry live-disable credential materialization order", () => { + it("rejects a missing workflow identity before reading a delegated credential file", async () => { + process.env.GITHUB_REPOSITORY = "ContextualWisdomLab/noema"; + delete process.env.NOEMA_MAINTAINER_TOKEN_PATH; + process.argv = ["node", "workflow-registry-live-disable.mjs"]; + + await expect(main()).rejects.toThrow( + "requested workflow id must be a positive safe integer", + ); + }); + + it("rejects a repository substitution before reading a delegated credential file", async () => { + process.env.GITHUB_REPOSITORY = "ContextualWisdomLab/other"; + delete process.env.NOEMA_MAINTAINER_TOKEN_PATH; + process.argv = ["node", "workflow-registry-live-disable.mjs", "101"]; + + await expect(main()).rejects.toThrow( + "workflow disablement is restricted to ContextualWisdomLab/noema", + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5fb55464e..903c7f5bf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ "scripts/prepare-agent-pr-message.mjs", "scripts/workflow-registry-audit.mjs", "scripts/workflow-registry-disable-plan.mjs", + "scripts/workflow-registry-live-disable.mjs", "scripts/production-environment-governance-audit.mjs", "scripts/lib/external-scheduler-evidence-audit.mjs", "scripts/lib/stable-file-evidence.mjs",