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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user