diff --git a/.github/workflows/hourly-commercial-readiness.yml b/.github/workflows/hourly-commercial-readiness.yml index 91657db0c..1c619e20e 100644 --- a/.github/workflows/hourly-commercial-readiness.yml +++ b/.github/workflows/hourly-commercial-readiness.yml @@ -163,16 +163,34 @@ jobs: - name: verify active main governance before any write id: governance env: - GH_TOKEN: ${{ steps.maintainer_app.outputs.token }} + DELEGATED_MAINTAINER_TOKEN: ${{ steps.maintainer_app.outputs.token }} NOEMA_GOVERNANCE_AUDIT_PATH: artifacts/governance/main-governance-audit.json - run: npm run governance:audit + run: | + set -euo pipefail + token_dir="$RUNNER_TEMP/noema-hourly-commercial-readiness" + token_path="$token_dir/maintainer-app-token" + mkdir -p "$token_dir" + umask 077 + printf '%s' "$DELEGATED_MAINTAINER_TOKEN" > "$token_path" + unset DELEGATED_MAINTAINER_TOKEN + trap 'rm -f "$token_path"' EXIT + NOEMA_MAINTAINER_TOKEN_PATH="$token_path" npm run governance:audit - name: inspect, dispatch, and merge exact-head pull requests id: loop env: - GH_TOKEN: ${{ steps.maintainer_app.outputs.token }} + DELEGATED_MAINTAINER_TOKEN: ${{ steps.maintainer_app.outputs.token }} NOEMA_REVIEWER_LOGIN: ${{ vars.NOEMA_REVIEWER_LOGIN }} - run: node scripts/hourly-commercial-readiness.mjs --apply + run: | + set -euo pipefail + token_dir="$RUNNER_TEMP/noema-hourly-commercial-readiness" + token_path="$token_dir/commercial-loop-token" + mkdir -p "$token_dir" + umask 077 + printf '%s' "$DELEGATED_MAINTAINER_TOKEN" > "$token_path" + unset DELEGATED_MAINTAINER_TOKEN + trap 'rm -f "$token_path"' EXIT + NOEMA_MAINTAINER_TOKEN_PATH="$token_path" node scripts/hourly-commercial-readiness.mjs --apply - name: refresh saleable-readiness evidence when the queue is empty if: steps.loop.outputs.remaining_open_pull_request_count == '0' diff --git a/.github/workflows/maintainer-app-readiness.yml b/.github/workflows/maintainer-app-readiness.yml index d5de37e4a..372fbebd1 100644 --- a/.github/workflows/maintainer-app-readiness.yml +++ b/.github/workflows/maintainer-app-readiness.yml @@ -75,9 +75,18 @@ jobs: id: governance continue-on-error: true env: - GH_TOKEN: ${{ steps.maintainer_app.outputs.token }} + DELEGATED_MAINTAINER_TOKEN: ${{ steps.maintainer_app.outputs.token }} NOEMA_GOVERNANCE_AUDIT_PATH: ${{ runner.temp }}/noema-maintainer-app-readiness/main-governance-audit.json - run: node scripts/main-governance-audit.mjs + run: | + set -euo pipefail + token_dir="$RUNNER_TEMP/noema-maintainer-app-readiness" + token_path="$token_dir/main-governance-token" + mkdir -p "$token_dir" + umask 077 + printf '%s' "$DELEGATED_MAINTAINER_TOKEN" > "$token_path" + unset DELEGATED_MAINTAINER_TOKEN + trap 'rm -f "$token_path"' EXIT + NOEMA_MAINTAINER_TOKEN_PATH="$token_path" node scripts/main-governance-audit.mjs - name: audit effective Maintainer App identity and access id: readiness @@ -109,7 +118,7 @@ jobs: if: always() continue-on-error: true env: - GH_TOKEN: ${{ steps.maintainer_app.outputs.token }} + DELEGATED_MAINTAINER_TOKEN: ${{ steps.maintainer_app.outputs.token }} NOEMA_REVIEWER_LOGIN: ${{ vars.NOEMA_REVIEWER_LOGIN }} MAINTAINER_APP_OUTCOME: ${{ steps.maintainer_app.outcome }} run: | @@ -153,8 +162,16 @@ jobs: exit 1 fi + token_dir="$RUNNER_TEMP/noema-maintainer-app-readiness" + token_path="$token_dir/commercial-loop-token" + mkdir -p "$token_dir" + umask 077 + printf '%s' "$DELEGATED_MAINTAINER_TOKEN" > "$token_path" + unset DELEGATED_MAINTAINER_TOKEN + trap 'rm -f "$token_path"' EXIT + set +e - node scripts/hourly-commercial-readiness.mjs --report "$report_path" + NOEMA_MAINTAINER_TOKEN_PATH="$token_path" node scripts/hourly-commercial-readiness.mjs --report "$report_path" loop_status=$? set -e if [ "$loop_status" -ne 0 ] && [ ! -s "$report_path" ]; then diff --git a/scripts/hourly-commercial-readiness.mjs b/scripts/hourly-commercial-readiness.mjs index e5990edc9..34b61b035 100644 --- a/scripts/hourly-commercial-readiness.mjs +++ b/scripts/hourly-commercial-readiness.mjs @@ -4,6 +4,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { evaluatePullRequest } from "./lib/commercial-readiness-loop.mjs"; +import { readDelegatedGithubToken } from "./lib/delegated-github-token.mjs"; const MAX_ERROR_CHARS = 4_000; const MAX_REPORT_DETAIL_CHARS = 1_000; @@ -57,7 +58,7 @@ export function createGhSubprocessEnvironment(sourceEnvironment) { function runGh(args, { input } = {}) { const childEnvironment = createGhSubprocessEnvironment({ PATH: process.env.PATH, - GH_TOKEN: process.env.GH_TOKEN, + GH_TOKEN: readDelegatedGithubToken(process.env.NOEMA_MAINTAINER_TOKEN_PATH), }); const completed = spawnSync("gh", args, { encoding: "utf8", diff --git a/scripts/lib/delegated-github-token.mjs b/scripts/lib/delegated-github-token.mjs new file mode 100644 index 000000000..4ef7cf6b0 --- /dev/null +++ b/scripts/lib/delegated-github-token.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; + +/** + * Load a short-lived delegated GitHub token from an explicit capability file. + * + * The file path is non-secret runtime configuration. The bearer token itself + * must not be read from the Node process environment. Callers are responsible + * for creating the file with restrictive permissions in trusted bootstrap code + * and deleting it after use. + */ +export function readDelegatedGithubToken(tokenPath) { + const path = String(tokenPath ?? "").trim(); + if (!path) { + throw new Error("Maintainer token file path is required."); + } + + let token; + try { + token = readFileSync(path, "utf8"); + } catch (error) { + throw new Error(`Maintainer token file could not be read: ${String(error?.message ?? error)}`); + } + + if (!token) { + throw new Error("Maintainer token file must not be empty."); + } + if (/[\u0000-\u001f\u007f]/.test(token)) { + throw new Error("Maintainer token must not contain control characters."); + } + return token; +} diff --git a/scripts/main-governance-audit.mjs b/scripts/main-governance-audit.mjs index 8f64976f1..82e050f08 100644 --- a/scripts/main-governance-audit.mjs +++ b/scripts/main-governance-audit.mjs @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { readDelegatedGithubToken } from "./lib/delegated-github-token.mjs"; import { evaluateMainGovernanceRules } from "./lib/main-governance-audit.mjs"; const MAX_ERROR_CHARS = 4_000; @@ -35,7 +36,7 @@ export function redactSensitiveValue(value, sensitiveValues = []) { return redacted; } -export function createGhSubprocessEnvironment(sourceEnvironment = process.env) { +export function createGhSubprocessEnvironment(sourceEnvironment = {}) { const childEnvironment = { GH_HOST: "github.com", NO_COLOR: "1", @@ -49,8 +50,11 @@ export function createGhSubprocessEnvironment(sourceEnvironment = process.env) { return childEnvironment; } -function runGh(args) { - const childEnvironment = createGhSubprocessEnvironment(); +function runGh(args, delegatedGithubToken) { + const childEnvironment = createGhSubprocessEnvironment({ + PATH: process.env.PATH, + GH_TOKEN: delegatedGithubToken, + }); const completed = spawnSync("gh", ["api", ...githubApiHeaders, ...args], { encoding: "utf8", maxBuffer: MAX_GH_OUTPUT_BYTES, @@ -70,8 +74,8 @@ function runGh(args) { return completed.stdout.trim(); } -function runGhJson(args) { - const raw = runGh(args); +function runGhJson(args, delegatedGithubToken) { + const raw = runGh(args, delegatedGithubToken); if (!raw) { throw new Error("GitHub CLI returned an empty active-rules response."); } @@ -177,16 +181,15 @@ export function main() { const repository = String(process.env.GITHUB_REPOSITORY ?? "").trim(); const reportPath = String(process.env.NOEMA_GOVERNANCE_AUDIT_PATH ?? defaultReportPath).trim() || defaultReportPath; + const tokenPath = String(process.env.NOEMA_MAINTAINER_TOKEN_PATH ?? "").trim(); let report; try { if (!repositoryPattern.test(repository)) { throw new Error("GITHUB_REPOSITORY must identify a ContextualWisdomLab repository."); } - if (!process.env.GH_TOKEN) { - throw new Error("GH_TOKEN is required for the governance audit."); - } + const delegatedGithubToken = readDelegatedGithubToken(tokenPath); const endpoint = `repos/${repository}/rules/branches/main?per_page=100`; - const pages = runGhJson(["--paginate", "--slurp", endpoint]); + const pages = runGhJson(["--paginate", "--slurp", endpoint], delegatedGithubToken); const rules = flattenRulePages(pages); report = buildReport(repository, rules, evaluateMainGovernanceRules(rules)); } catch (error) { diff --git a/test/github-credential-capability-ingress.test.ts b/test/github-credential-capability-ingress.test.ts new file mode 100644 index 000000000..e3365afa8 --- /dev/null +++ b/test/github-credential-capability-ingress.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readDelegatedGithubToken } from "../scripts/lib/delegated-github-token.mjs"; + +const temporaryDirectories: string[] = []; + +function temporaryFile(contents: string) { + const directory = mkdtempSync(join(tmpdir(), "noema-token-capability-")); + temporaryDirectories.push(directory); + const path = join(directory, "token"); + writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 }); + return path; +} + +function stepBlock(workflow: string, name: string) { + const start = workflow.indexOf(name); + const nextStep = workflow.indexOf("\n - name:", start + 1); + expect(start).toBeGreaterThanOrEqual(0); + expect(nextStep).toBeGreaterThan(start); + return workflow.slice(start, nextStep); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("GitHub credential capability ingress", () => { + it("reads a non-empty control-free delegated token from the explicit capability path", () => { + const path = temporaryFile("delegated-token-value"); + expect(readDelegatedGithubToken(path)).toBe("delegated-token-value"); + }); + + it("fails closed for missing, unreadable, empty, and control-bearing capability files", () => { + expect(() => readDelegatedGithubToken("")).toThrow("Maintainer token file path is required."); + expect(() => readDelegatedGithubToken("/definitely/not/a/noema/token")).toThrow( + "Maintainer token file could not be read:", + ); + expect(() => readDelegatedGithubToken(temporaryFile(""))).toThrow( + "Maintainer token file must not be empty.", + ); + expect(() => readDelegatedGithubToken(temporaryFile("token\nvalue"))).toThrow( + "Maintainer token must not contain control characters.", + ); + }); + + it("keeps delegated GitHub bearer tokens out of Node process-environment reads", () => { + for (const scriptPath of [ + "scripts/main-governance-audit.mjs", + "scripts/hourly-commercial-readiness.mjs", + ]) { + const script = readFileSync(scriptPath, "utf8"); + expect(script).toContain("NOEMA_MAINTAINER_TOKEN_PATH"); + expect(script).toContain("readDelegatedGithubToken"); + expect(script).not.toContain("process.env.GH_TOKEN"); + } + }); + + it("bootstraps governance and commercial-loop callers through restrictive ephemeral capability files", () => { + const workflowCases = [ + { + path: ".github/workflows/hourly-commercial-readiness.yml", + steps: ["verify active main governance before any write", "inspect, dispatch, and merge exact-head pull requests"], + }, + { + path: ".github/workflows/maintainer-app-readiness.yml", + steps: ["audit active main governance", "inspect commercial-readiness loop without writes"], + }, + ]; + + for (const workflowCase of workflowCases) { + const workflow = readFileSync(workflowCase.path, "utf8"); + for (const stepName of workflowCase.steps) { + const block = stepBlock(workflow, stepName); + expect(block).toContain("DELEGATED_MAINTAINER_TOKEN: ${{ steps.maintainer_app.outputs.token }}"); + expect(block).toContain("NOEMA_MAINTAINER_TOKEN_PATH"); + expect(block).toContain("umask 077"); + expect(block).toContain("unset DELEGATED_MAINTAINER_TOKEN"); + expect(block).toContain("trap 'rm -f \"$token_path\"' EXIT"); + expect(block).not.toContain("GH_TOKEN: ${{ steps.maintainer_app.outputs.token }}"); + } + } + }); +}); diff --git a/test/hourly-commercial-readiness-toolchain-integrity.test.ts b/test/hourly-commercial-readiness-toolchain-integrity.test.ts index e648bd6c6..b06bd0ce2 100644 --- a/test/hourly-commercial-readiness-toolchain-integrity.test.ts +++ b/test/hourly-commercial-readiness-toolchain-integrity.test.ts @@ -85,13 +85,21 @@ describe("commercial writer toolchain integrity", () => { const tokenConsumers = workflowSteps() .filter((step) => - step.block.includes("GH_TOKEN: ${{ steps.maintainer_app.outputs.token }}"), + step.block.includes("DELEGATED_MAINTAINER_TOKEN: ${{ steps.maintainer_app.outputs.token }}"), ) .map((step) => step.name); expect(tokenConsumers).toEqual([ "verify active main governance before any write", "inspect, dispatch, and merge exact-head pull requests", ]); + for (const stepName of tokenConsumers) { + const block = uniqueStep(stepName).block; + expect(block).toContain("NOEMA_MAINTAINER_TOKEN_PATH"); + expect(block).toContain("umask 077"); + expect(block).toContain("unset DELEGATED_MAINTAINER_TOKEN"); + expect(block).toContain("trap 'rm -f \"$token_path\"' EXIT"); + } + expect(workflow).not.toContain("GH_TOKEN: ${{ steps.maintainer_app.outputs.token }}"); expect(workflow).not.toContain("GH_TOKEN: ${{ github.token }}"); }); diff --git a/test/main-governance-audit-script.test.ts b/test/main-governance-audit-script.test.ts index 5d726fb96..483646fef 100644 --- a/test/main-governance-audit-script.test.ts +++ b/test/main-governance-audit-script.test.ts @@ -79,6 +79,8 @@ describe("main governance audit GitHub adapter", () => { expect(script).toContain('["--paginate", "--slurp", endpoint]'); expect(script).toContain("rules/branches/main?per_page=100"); expect(script).toContain("evaluateMainGovernanceRules"); + expect(script).toContain("NOEMA_MAINTAINER_TOKEN_PATH"); + expect(script).not.toContain("process.env.GH_TOKEN"); }); it("writes single-line bounded evidence, outputs, and a workflow summary without leaking the token", () => { @@ -100,10 +102,10 @@ describe("main governance audit GitHub adapter", () => { expect(script).not.toContain("JSON.stringify(process.env"); }); - it("fails closed when credentials, the audit, or collection do not pass", () => { + it("fails closed when the capability, audit, or collection do not pass", () => { const script = readFileSync("scripts/main-governance-audit.mjs", "utf8"); - expect(script).toContain("GH_TOKEN is required for the governance audit."); + expect(script).toContain("readDelegatedGithubToken(tokenPath)"); expect(script).toContain('if (report.status !== "PASS")'); expect(script).toContain("process.exitCode = 1"); expect(script).toContain('status: "FAIL"'); diff --git a/test/workflow-readiness.test.ts b/test/workflow-readiness.test.ts index 5202f2af4..d6961f63a 100644 --- a/test/workflow-readiness.test.ts +++ b/test/workflow-readiness.test.ts @@ -91,7 +91,9 @@ describe("deployment workflow readiness gates", () => { ]) { expect(workflow).toContain(permission); } - expect(workflow).toContain("GH_TOKEN: ${{ steps.maintainer_app.outputs.token }}"); + expect(workflow).toContain("DELEGATED_MAINTAINER_TOKEN: ${{ steps.maintainer_app.outputs.token }}"); + expect(workflow).toContain("NOEMA_MAINTAINER_TOKEN_PATH"); + expect(workflow).not.toContain("GH_TOKEN: ${{ steps.maintainer_app.outputs.token }}"); expect(workflow).not.toContain("GH_TOKEN: ${{ github.token }}"); expect(workflow).toContain("permissions:\n contents: read"); expect(workflow).not.toContain("id-token: write");