fix: require typed operator authorization for real releases

Remove the self-grantable FUSION_RELEASE_AUTHORIZED env signal and replace
it with an interactive prompt: a real release now requires a live human to
type "authorized" at a TTY. Releases can no longer run non-interactively
(no TTY is blocked outright), and --yes does not bypass the typed phrase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-08 09:00:47 -07:00
parent 13570e86aa
commit 01be51bf35
3 changed files with 99 additions and 63 deletions

View File

@@ -5,66 +5,68 @@ import { URL } from "node:url";
import {
evaluateReleaseAuthorization,
RELEASE_AUTHORIZATION_ENV,
isReleaseAuthorizationPhrase,
RELEASE_AUTHORIZATION_PHRASE,
} from "../lib/release-authorization-gate.mjs";
test("gate blocks real release without signal in non-interactive FN-6469 path", () => {
const result = evaluateReleaseAuthorization({ dryRun: false, env: {}, stdinIsTTY: false });
test("gate blocks a real release run non-interactively (no TTY)", () => {
const result = evaluateReleaseAuthorization({ dryRun: false, stdinIsTTY: false });
assert.equal(result.authorized, false);
assert.equal(result.mode, "blocked");
assert.match(result.reason ?? "", /non-interactive shell/);
assert.match(result.reason ?? "", /non-interactively/);
assert.match(result.reason ?? "", /aborted before version bump, publish, push, or tag/);
});
test("gate allows real release with explicit operator signal", () => {
const result = evaluateReleaseAuthorization({
dryRun: false,
env: { [RELEASE_AUTHORIZATION_ENV]: "operator-held-one-time-approval" },
stdinIsTTY: false,
});
test("interactive real release requires the typed authorization phrase", () => {
const result = evaluateReleaseAuthorization({ dryRun: false, stdinIsTTY: true });
assert.deepEqual(result, { authorized: true, mode: "env-signal" });
assert.deepEqual(result, { authorized: false, mode: "requires-confirmation" });
});
test("dry-run bypasses authorization because it publishes nothing", () => {
const result = evaluateReleaseAuthorization({ dryRun: true, env: {}, stdinIsTTY: false });
const result = evaluateReleaseAuthorization({ dryRun: true, stdinIsTTY: false });
assert.deepEqual(result, { authorized: true, mode: "dry-run-bypass" });
});
test("empty or whitespace-only authorization signal fails closed", () => {
for (const value of ["", " ", "\n\t"]) {
const result = evaluateReleaseAuthorization({
dryRun: false,
env: { [RELEASE_AUTHORIZATION_ENV]: value },
stdinIsTTY: false,
});
test("only the exact authorization phrase passes; anything else fails closed", () => {
assert.equal(isReleaseAuthorizationPhrase(RELEASE_AUTHORIZATION_PHRASE), true);
assert.equal(isReleaseAuthorizationPhrase(" Authorized "), true);
assert.equal(isReleaseAuthorizationPhrase("AUTHORIZED\n"), true);
assert.equal(result.authorized, false, `expected ${JSON.stringify(value)} to be blocked`);
assert.equal(result.mode, "blocked");
for (const value of ["", " ", "yes", "y", "authorize", "authorized now", undefined, null]) {
assert.equal(
isReleaseAuthorizationPhrase(value),
false,
`expected ${JSON.stringify(value)} to be rejected`,
);
}
});
test("TTY presence alone does not authorize a real release", () => {
const result = evaluateReleaseAuthorization({ dryRun: false, env: {}, stdinIsTTY: true });
assert.equal(result.authorized, false);
assert.equal(result.mode, "blocked");
assert.match(result.reason ?? "", /interactive shell/);
});
test("release script imports and enforces the authorization gate after dry-run exit", () => {
test("release script prompts for the authorization phrase before the first mutation", () => {
const source = readFileSync(new URL("../release.mjs", import.meta.url), "utf8");
const importIndex = source.indexOf("./lib/release-authorization-gate.mjs");
const dryRunExitIndex = source.indexOf("if (DRY_RUN) {");
const gateIndex = source.indexOf("evaluateReleaseAuthorization({");
const phraseCheckIndex = source.indexOf("isReleaseAuthorizationPhrase(");
const versionBumpIndex = source.indexOf("run(\"pnpm release:version\")");
assert.notEqual(importIndex, -1, "release.mjs should import the authorization helper");
assert.notEqual(dryRunExitIndex, -1, "release.mjs should retain the dry-run early exit");
assert.notEqual(gateIndex, -1, "release.mjs should call evaluateReleaseAuthorization()");
assert.notEqual(phraseCheckIndex, -1, "release.mjs should validate the typed authorization phrase");
assert.notEqual(versionBumpIndex, -1, "release.mjs should still run the version bump after gates");
assert.ok(dryRunExitIndex < gateIndex, "dry-run must exit before the authorization gate call site");
assert.ok(gateIndex < versionBumpIndex, "authorization must be checked before the first mutation");
assert.ok(gateIndex < phraseCheckIndex, "the gate decision must precede the typed-phrase check");
assert.ok(phraseCheckIndex < versionBumpIndex, "authorization must be checked before the first mutation");
});
test("env vars no longer influence release authorization", () => {
const source = readFileSync(
new URL("../lib/release-authorization-gate.mjs", import.meta.url),
"utf8",
);
assert.ok(!/FUSION_RELEASE_AUTHORIZED/.test(source), "the env signal must be fully removed");
assert.ok(!/process\.env/.test(source), "the gate must not read process env");
});

View File

@@ -1,30 +1,51 @@
export const RELEASE_AUTHORIZATION_ENV = "FUSION_RELEASE_AUTHORIZED";
/**
* FNXC:ReleaseScript 2026-07-08-11:20:
* The former operator-held environment signal is removed. An env var is self-grantable (an agent can `export` it) and survives into non-interactive shells, so it never proved a live human. Real releases now require a human to type the authorization phrase at an interactive prompt, and a release can no longer be run non-interactively at all.
*
* FN-6469 proved that branch and working-tree preflight checks are not an authorization boundary because an agent can clone `main` into a fresh directory and rerun `pnpm release --yes`. The interactive-typed phrase closes that hole: it cannot be satisfied without a TTY-attached human, and `--yes` does not bypass it.
*/
/**
* FNXC:ReleaseScript 2026-06-15-02:41:
* FN-6469 proved that branch and working-tree preflight checks are not an authorization boundary because an agent can clone `main` into a fresh directory and rerun `pnpm release --yes`.
* Real releases are not agent-initiable: the publish path requires an explicit operator-held environment signal that is outside repo state and cannot be self-granted by reproducing `main`; dry-runs bypass this gate because they publish nothing.
*
* @param {{ dryRun: boolean, env?: Record<string, string | undefined>, stdinIsTTY?: boolean }} options
* @returns {{ authorized: boolean, mode: "dry-run-bypass" | "env-signal" | "blocked", reason?: string }}
* The exact phrase an operator must type to authorize a real release.
* Matched case-insensitively after trimming surrounding whitespace.
*/
export function evaluateReleaseAuthorization({ dryRun, env = {}, stdinIsTTY = false }) {
export const RELEASE_AUTHORIZATION_PHRASE = "authorized";
/**
* FNXC:ReleaseScript 2026-07-08-11:20:
* Pure decision for how release authorization must be obtained. Real releases require a live operator: dry-runs publish nothing and bypass; a non-interactive shell (no TTY) is blocked outright because the authorization phrase cannot be typed; an interactive shell must prompt for the typed phrase (see {@link isReleaseAuthorizationPhrase}).
*
* @param {{ dryRun: boolean, stdinIsTTY?: boolean }} options
* @returns {{ authorized: boolean, mode: "dry-run-bypass" | "requires-confirmation" | "blocked", reason?: string }}
*/
export function evaluateReleaseAuthorization({ dryRun, stdinIsTTY = false }) {
if (dryRun === true) {
return { authorized: true, mode: "dry-run-bypass" };
}
const signal = env[RELEASE_AUTHORIZATION_ENV];
if (typeof signal === "string" && signal.trim() !== "") {
return { authorized: true, mode: "env-signal" };
if (stdinIsTTY !== true) {
return {
authorized: false,
mode: "blocked",
reason:
"Releases cannot be run non-interactively: no TTY is attached, so the operator authorization phrase cannot be typed. " +
`Run \`pnpm release\` from an interactive terminal and type "${RELEASE_AUTHORIZATION_PHRASE}" when prompted; aborted before version bump, publish, push, or tag.`,
};
}
const shellContext = stdinIsTTY
? "No operator authorization signal was present in this interactive shell."
: "No operator authorization signal was present in this non-interactive shell.";
return {
authorized: false,
mode: "blocked",
reason: `${shellContext} Real releases require explicit operator authorization via ${RELEASE_AUTHORIZATION_ENV}; aborted before version bump, publish, push, or tag.`,
};
return { authorized: false, mode: "requires-confirmation" };
}
/**
* FNXC:ReleaseScript 2026-07-08-11:20:
* Validate what the operator typed at the authorization prompt. Only the exact phrase (case-insensitive, whitespace-trimmed) authorizes the release; anything else fails closed.
*
* @param {unknown} input raw string the operator typed
* @returns {boolean}
*/
export function isReleaseAuthorizationPhrase(input) {
return (
typeof input === "string" &&
input.trim().toLowerCase() === RELEASE_AUTHORIZATION_PHRASE
);
}

View File

@@ -10,13 +10,14 @@
// - clean working tree on `main`, up to date with origin
// - at least one pending changeset in .changeset/
// - `npm login` already completed (publish uses the active npm token)
// - real releases require an operator-held FUSION_RELEASE_AUTHORIZED signal;
// dry-runs do not require it because they make no file/git/npm changes
// - real releases require a live operator to type the authorization phrase
// ("authorized") at an interactive prompt; they cannot run non-interactively.
// Dry-runs skip this because they make no file/git/npm changes
//
// Usage:
// pnpm release # interactive: review changesets, accept or override version, confirm, then require operator authorization before mutation
// pnpm release --yes # accept the proposed version, skip confirmation prompt, still require operator authorization before mutation
// pnpm release --dry-run # preview only; non-interactive by default; no authorization signal or file/git/npm changes
// pnpm release # interactive: review changesets, accept or override version, type the authorization phrase, then confirm before mutation
// pnpm release --yes # accept the proposed version, skip the y/N confirmation prompt, but STILL require the typed authorization phrase before mutation
// pnpm release --dry-run # preview only; non-interactive by default; no authorization or file/git/npm changes
// pnpm release --dry-run --interactive
// # preview only, but exercise the version prompt override
@@ -27,7 +28,11 @@ import { tmpdir } from "node:os";
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { evaluateReleaseAuthorization } from "./lib/release-authorization-gate.mjs";
import {
evaluateReleaseAuthorization,
isReleaseAuthorizationPhrase,
RELEASE_AUTHORIZATION_PHRASE,
} from "./lib/release-authorization-gate.mjs";
import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs";
import { parseChangesetFile } from "./lib/changeset-schema.mjs";
import { distillDeterministic } from "./lib/distill-release-notes.mjs";
@@ -543,21 +548,29 @@ if (DRY_RUN) {
}
/*
* FNXC:ReleaseScript 2026-06-15-02:45:
* FN-6469 showed `main`-branch preflight is bypassable by cloning a clean `main`; require an out-of-tree operator-held authorization signal before any version bump, publish, push, tag, GitHub Release, or Homebrew tap mutation can begin.
* Dry-run exits above so agents can still inspect release plans without the signal.
* FNXC:ReleaseScript 2026-07-08-11:20:
* FN-6469 showed `main`-branch preflight is bypassable by cloning a clean `main`. A real release now requires a live human to type the authorization phrase at an interactive prompt before any version bump, publish, push, tag, GitHub Release, or Homebrew tap mutation can begin. This replaces the removed `FUSION_RELEASE_AUTHORIZED` env signal, which was self-grantable and leaked into non-interactive shells. `--yes` does not bypass this prompt; a non-interactive shell is blocked outright. Dry-run exits above so agents can still inspect release plans without authorization.
*/
const releaseAuthorization = evaluateReleaseAuthorization({
dryRun: DRY_RUN,
env: process.env,
stdinIsTTY: process.stdin.isTTY === true,
});
if (!releaseAuthorization.authorized) {
if (releaseAuthorization.mode === "blocked") {
fail(
`${releaseAuthorization.reason ?? "Release is not authorized."}\n` +
"Releases are not agent-initiable. A human operator must provide the operator-held FUSION_RELEASE_AUTHORIZED signal from outside the repository before invoking a real release.",
"Releases are not agent-initiable and cannot run non-interactively.",
);
}
if (releaseAuthorization.mode === "requires-confirmation") {
const typed = await ask(
`Type "${RELEASE_AUTHORIZATION_PHRASE}" to authorize this real release (build, publish, tag): `,
);
if (!isReleaseAuthorizationPhrase(typed)) {
fail(
`Authorization phrase not entered ("${RELEASE_AUTHORIZATION_PHRASE}" required); aborted before version bump, publish, push, or tag.`,
);
}
}
if (!(await confirm(`Proceed with release v${chosenVersion} (build, publish, tag)?`))) {
warn("Aborted by user.");