Skip to content
15 changes: 12 additions & 3 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,18 @@ jobs:

- name: Audit production environment deployment protections
env:
GH_TOKEN: ${{ github.token }}
DELEGATED_MAINTAINER_TOKEN: ${{ github.token }}
NOEMA_PRODUCTION_ENVIRONMENT_GOVERNANCE_PATH: artifacts/governance/production-environment-governance.json
run: npm run production:governance
run: |
set -euo pipefail
umask 077
token_dir="$(mktemp -d "$RUNNER_TEMP/noema-production-governance.XXXXXX")"
token_path="$token_dir/maintainer-app-token"
trap 'rm -rf "$token_dir"' EXIT
printf '%s' "$DELEGATED_MAINTAINER_TOKEN" > "$token_path"
chmod 0600 "$token_path"
unset DELEGATED_MAINTAINER_TOKEN
NOEMA_MAINTAINER_TOKEN_PATH="$token_path" npm run production:governance

- name: Production evidence preflight
env:
Expand Down Expand Up @@ -250,7 +259,7 @@ jobs:
repository: $repository,
releaseTag: $releaseTag,
commitSha: $commitSha,
deploymentEvidenceSha256: $deploymentEvidenceSha256,
deploymentEvidenceSha256: $deployment_digest,
signerWorkflow: $signerWorkflow,
predicateType: $predicateType,
oidcIssuer: $oidcIssuer,
Expand Down
49 changes: 38 additions & 11 deletions scripts/production-environment-governance-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 as readHardenedDelegatedGithubToken } from "./lib/delegated-github-token.mjs";
import { evaluateProductionEnvironment } from "./lib/production-environment-governance.mjs";
import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs";

Expand Down Expand Up @@ -36,10 +37,21 @@ export function redactSensitiveValue(value, sensitiveValues = []) {
return redacted;
}

/**
* Read a short-lived GitHub credential from the repository's hardened,
* descriptor-safe capability-file boundary.
*
* @param {unknown} tokenPath Explicit non-secret capability-file path.
* @returns {string} Exact delegated GitHub token bytes decoded as UTF-8.
*/
export function readDelegatedGithubToken(tokenPath) {
return readHardenedDelegatedGithubToken(tokenPath);
}

/**
* Build the least-authority environment passed to the read-only GitHub CLI.
*
* @param {NodeJS.ProcessEnv} [sourceEnvironment=process.env] Ambient process environment.
* @param {NodeJS.ProcessEnv} [sourceEnvironment=process.env] Explicit credential/config source.
* @returns {Record<string, string>} Allow-listed child-process environment.
*/
export function createGhSubprocessEnvironment(sourceEnvironment = process.env) {
Expand Down Expand Up @@ -74,9 +86,9 @@ export function decodeGhOutput(value, label = "output") {

/**
* Execute one bounded GitHub CLI request for production-governance evidence.
* Runtime callers use the real shell-free spawn implementation and current
* process environment. Tests may inject only these two boundaries so failure
* byte selection, UTF-8 handling, and secret redaction are exercised directly.
* Runtime callers pass only an explicitly read delegated credential; tests may
* inject the subprocess primitive and an explicit environment to exercise the
* error/redaction boundary without granting network or ambient secret access.
*
* @param {string[]} args GitHub CLI arguments.
* @param {{sourceEnvironment?: NodeJS.ProcessEnv, spawnSyncImpl?: typeof spawnSync}} [options]
Expand Down Expand Up @@ -115,15 +127,21 @@ export function runGh(
return decodeGhOutput(completed.stdout, "stdout").trim();
}

function collectEnvironment(repository, runGhImpl) {
const raw = runGhImpl([
function collectEnvironment(repository, runGhImpl, delegatedGithubToken, sourceEnvironment) {
const args = [
"api",
"-H",
"Accept: application/vnd.github+json",
"-H",
"X-GitHub-Api-Version: 2026-03-10",
`repos/${repository}/environments/production`,
]);
];
const raw = runGhImpl(args, {
sourceEnvironment: {
PATH: sourceEnvironment.PATH,
GH_TOKEN: delegatedGithubToken,
},
});
if (!raw) {
throw new Error("GitHub CLI returned an empty production environment response.");
}
Expand Down Expand Up @@ -203,9 +221,9 @@ function buildFailureReport(repository, error) {

/**
* Collect and evaluate the live production-environment governance evidence.
* Dependency injection is deliberately limited to the read-only GitHub CLI,
* process environment, logging sink, and exit-code sink so realistic tests can
* exercise every evidence boundary without granting network or write authority.
* The production entrypoint requires a descriptor-safe delegated credential.
* Dependency-injected GitHub clients remain credential-free test seams so
* malformed/hostile evidence can be exercised without network authority.
*
* @param {{
* sourceEnvironment?: NodeJS.ProcessEnv,
Expand All @@ -229,12 +247,21 @@ export function main(
const reportPath = String(
sourceEnvironment.NOEMA_PRODUCTION_ENVIRONMENT_GOVERNANCE_PATH ?? defaultReportPath,
).trim() || defaultReportPath;
const tokenPath = String(sourceEnvironment.NOEMA_MAINTAINER_TOKEN_PATH ?? "").trim();
let report;
try {
if (!repositoryPattern.test(repository)) {
throw new Error("GITHUB_REPOSITORY must identify a ContextualWisdomLab repository.");
}
const environment = collectEnvironment(repository, runGhImpl);
const delegatedGithubToken = runGhImpl === runGh
? readDelegatedGithubToken(tokenPath)
: null;
const environment = collectEnvironment(
repository,
runGhImpl,
delegatedGithubToken,
sourceEnvironment,
);
const evaluation = evaluateProductionEnvironment(environment);
report = {
schema_version: 1,
Expand Down
114 changes: 114 additions & 0 deletions test/production-environment-governance-token-capability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
main,
readDelegatedGithubToken,
} from "../scripts/production-environment-governance-audit.mjs";

const temporaryDirectories: string[] = [];

function temporaryDirectory() {
const directory = mkdtempSync(join(tmpdir(), "noema-production-governance-token-"));
temporaryDirectories.push(directory);
return directory;
}

function tokenFile(mode = 0o600) {
const directory = temporaryDirectory();
const path = join(directory, "maintainer-app-token");
writeFileSync(path, "short-lived-maintainer-token", { encoding: "utf8", mode });
chmodSync(path, mode);
return path;
}

afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});

describe("production environment governance GitHub credential ingress", () => {
it("reads only an owner-only delegated capability file", () => {
expect(readDelegatedGithubToken(tokenFile())).toBe("short-lived-maintainer-token");

expect(() => readDelegatedGithubToken(tokenFile(0o640))).toThrow(
"Maintainer token file permissions must be owner-only.",
);
});

it("refuses a symlinked delegated token capability", () => {
const directory = temporaryDirectory();
const target = join(directory, "real-token");
const link = join(directory, "token-link");
writeFileSync(target, "short-lived-maintainer-token", { encoding: "utf8", mode: 0o600 });
symlinkSync(target, link);

expect(() => readDelegatedGithubToken(link)).toThrow(
"Maintainer token file could not be opened safely:",
);
});

it("fails closed instead of falling back to an ambient GH_TOKEN", () => {
const directory = temporaryDirectory();
const report = main({
sourceEnvironment: {
GITHUB_REPOSITORY: "ContextualWisdomLab/noema",
GH_TOKEN: "ambient-token-must-not-be-used",
NOEMA_PRODUCTION_ENVIRONMENT_GOVERNANCE_PATH: join(directory, "report.json"),
},
log: () => undefined,
setExitCode: () => undefined,
});

expect(report).toMatchObject({ status: "FAIL" });
expect(report.failures[0]).toMatchObject({
code: "production_environment_collection_failed",
detail: "Maintainer token file path is required.",
});
expect(readFileSync(join(directory, "report.json"), "utf8")).not.toContain(
"ambient-token-must-not-be-used",
);
});

it("bootstraps the deployment audit through the token capability instead of GH_TOKEN", () => {
const workflow = readFileSync(".github/workflows/cd.yml", "utf8");
const auditStart = workflow.indexOf("- name: Audit production environment deployment protections");
const auditEnd = workflow.indexOf("- name: Production evidence preflight", auditStart);
const auditStep = workflow.slice(auditStart, auditEnd);

expect(auditStart).toBeGreaterThan(-1);
expect(auditEnd).toBeGreaterThan(auditStart);
expect(auditStep).toContain("NOEMA_MAINTAINER_TOKEN_PATH");
expect(auditStep).toContain("umask 077");
expect(auditStep).toContain("chmod 0600");
expect(auditStep).not.toContain("GH_TOKEN: ${{ github.token }}");
});

it("creates a fresh private directory before writing delegated token bytes", () => {
const workflow = readFileSync(".github/workflows/cd.yml", "utf8");
const auditStart = workflow.indexOf("- name: Audit production environment deployment protections");
const auditEnd = workflow.indexOf("- name: Production evidence preflight", auditStart);
const auditStep = workflow.slice(auditStart, auditEnd);
const umaskIndex = auditStep.indexOf("umask 077");
const mktempIndex = auditStep.indexOf(
'token_dir="$(mktemp -d "$RUNNER_TEMP/noema-production-governance.XXXXXX")"',
);

expect(auditStart).toBeGreaterThan(-1);
expect(auditEnd).toBeGreaterThan(auditStart);
expect(umaskIndex).toBeGreaterThan(-1);
expect(mktempIndex).toBeGreaterThan(umaskIndex);
expect(auditStep).not.toContain('token_dir="$RUNNER_TEMP/noema-production-governance"');
expect(auditStep).not.toContain('mkdir -p "$token_dir"');
expect(auditStep).toContain("trap 'rm -rf \"$token_dir\"' EXIT");
});
});
Loading