From a217393b171f4a8239bb78b0cbea192247c1709d Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:17:27 +0200 Subject: [PATCH 1/3] Consume the canonical attestation gate instead of carrying a copy of it This repository carried its own 837-line shell scanner and 426-line verifier, duplicated from the same origin as every other package in the fleet. That gate decides whether an artefact may reach the registry, so a false pass is the failure that matters - and the canonical implementation has now had fifteen fail-open constructions found and closed in it, three of them introduced by the fix for an earlier one. A copy frozen at any point in that sequence still admits every construction closed after it, and nothing here would have said so. The scanner is deleted, the verifier becomes a thin launcher over the published pm-ops/attestation export, and the changelog-date verifier's shell script replaces a variable in command position with a named function, because the canonical auditor treats an unresolved variable there as a potential publish path by design. 2269 lines go, 151 arrive. The suite changes shape deliberately. It no longer re-tests the shell model: that suite belongs with the implementation, where a fix reaches every consumer at once. It asserts instead that this repository is still a CONSUMER - no local scanner, the gate importing the package rather than resolving any part of its shell model locally, and the resolved gate still refusing an unattested publish and passing on this repository's own workflows. Identity rather than similarity, because a copy that behaves the same today is exactly what stops behaving the same the next time the canonical implementation is fixed. --- .agents/pm/chores/pm-github-u5qc.toon | 18 + .agents/pm/history/pm-github-u5qc.jsonl | 5 + CHANGELOG.md | 6 + package-lock.json | 8 +- package.json | 2 +- scripts/shell-command-scan.ts | 837 ------------- scripts/verify-release-changelog-date.sh | 18 +- scripts/verify-release-publish-attestation.ts | 421 +------ test/shell-command-scan.test.ts | 55 - ...verify-release-publish-attestation.test.ts | 1073 ++--------------- 10 files changed, 174 insertions(+), 2269 deletions(-) create mode 100644 .agents/pm/chores/pm-github-u5qc.toon create mode 100644 .agents/pm/history/pm-github-u5qc.jsonl delete mode 100644 scripts/shell-command-scan.ts delete mode 100644 test/shell-command-scan.test.ts diff --git a/.agents/pm/chores/pm-github-u5qc.toon b/.agents/pm/chores/pm-github-u5qc.toon new file mode 100644 index 0000000..ef2987a --- /dev/null +++ b/.agents/pm/chores/pm-github-u5qc.toon @@ -0,0 +1,18 @@ +id: pm-github-u5qc +title: Consume the canonical attestation gate instead of carrying a copy of it +description: "This repository carried its own 837-line shell scanner and 426-line verifier, duplicated from the same origin as every other package in the fleet. That gate decides whether an artefact may reach the registry, so a false pass is the failure that matters. The canonical implementation (pm-ops/attestation and pm-ops/shell-scan) has had fifteen fail-open constructions closed in it; the vendored copy is frozen and still admits constructions closed after that. BEFORE check.sh: 11 wrong. AFTER target: nonliteral-overwrite,nonliteral-overwrite-cmdsub only (plus any new corpus cases open in canonical)." +type: Chore +status: closed +priority: 1 +tags[2]: attestation,security +created_at: "2026-09-07T10:05:34.490Z" +updated_at: "2026-09-07T10:16:57.814Z" +closed_at: "2026-09-07T10:16:57.788Z" +completed_at: "2026-09-07T10:16:57.788Z" +claim_principal: pi-agent +author: pi-agent +acceptance_criteria: Delete scripts/shell-command-scan.ts; Rewrite scripts/verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation; Replace test suite to assert consumption not re-test shell model +comments[1]{created_at,author,text}: + "2026-09-07T10:16:40.082Z",pi-agent,"AFTER check.sh: nonliteral-overwrite,nonliteral-overwrite-cmdsub,quoted-metachar-value,single-quoted-metachar-value (4 wrong). Also fixed scripts/verify-release-changelog-date.sh: replaced $bin variable in command position with a run_changelog function, because the canonical auditor flags unresolved variables in command position as potential publish paths (by design - fail closed). BEFORE: 11 wrong. AFTER: 4 wrong, all 4 open in canonical pm-ops@2026.9.7." +close_reason: "Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass)." +body: "" diff --git a/.agents/pm/history/pm-github-u5qc.jsonl b/.agents/pm/history/pm-github-u5qc.jsonl new file mode 100644 index 0000000..b80bef0 --- /dev/null +++ b/.agents/pm/history/pm-github-u5qc.jsonl @@ -0,0 +1,5 @@ +{"ts":"2026-09-07T10:05:34.490Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-u5qc"},{"op":"add","path":"/metadata/title","value":"Consume the canonical attestation gate instead of carrying a copy of it"},{"op":"add","path":"/metadata/description","value":"This repository carried its own 837-line shell scanner and 426-line verifier, duplicated from the same origin as every other package in the fleet. That gate decides whether an artefact may reach the registry, so a false pass is the failure that matters. The canonical implementation (pm-ops/attestation and pm-ops/shell-scan) has had fifteen fail-open constructions closed in it; the vendored copy is frozen and still admits constructions closed after that. BEFORE check.sh: 11 wrong. AFTER target: nonliteral-overwrite,nonliteral-overwrite-cmdsub only (plus any new corpus cases open in canonical)."},{"op":"add","path":"/metadata/type","value":"Chore"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["attestation","security"]},{"op":"add","path":"/metadata/created_at","value":"2026-09-07T10:05:34.490Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-09-07T10:05:34.490Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Delete scripts/shell-command-scan.ts; Rewrite scripts/verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation; Replace test suite to assert consumption not re-test shell model"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"f382ed69201a76e63eacd2f3b1e39417c05a1515fd4b24ac81557cf76e088980","item_hash_version":3,"message":"","event_class":"substantive","record_hash_version":1,"record_hash":"3a0fc37fa19d84480df28a9978c0790e49c175ead4bde5bf8d76e303d96ccd7b"} +{"ts":"2026-09-07T10:05:41.772Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:05:41.772Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"},{"op":"add","path":"/metadata/claim_principal","value":"pi-agent"}],"before_hash":"f382ed69201a76e63eacd2f3b1e39417c05a1515fd4b24ac81557cf76e088980","after_hash":"4cfe822bbf65e10c1064dc780ab18cd1d71720a178a685dc95cc1471587afab5","item_hash_version":3,"message":"Starting convergence: delete vendored scanner, rewrite attestation gate as thin launcher over pm-ops/attestation","event_class":"substantive","record_hash_version":1,"record_hash":"4e58a9e766385e57a985bf00bc8b32725ef3571d205ed98b757929dcdd5643bd"} +{"ts":"2026-09-07T10:05:41.813Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:05:41.813Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"4cfe822bbf65e10c1064dc780ab18cd1d71720a178a685dc95cc1471587afab5","after_hash":"64e9836a075318fa7c25073d8915b2f50967d13d8000abc19b1d86666c19bd1c","item_hash_version":3,"message":"Starting convergence: delete vendored scanner, rewrite attestation gate as thin launcher over pm-ops/attestation","event_class":"substantive","record_hash_version":1,"record_hash":"59049802ce12a9804f5eaa7e647df73710d19fb2b7925d77f826a46456716bd1"} +{"ts":"2026-09-07T10:16:40.082Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:16:40.082Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-09-07T10:16:40.082Z","author":"pi-agent","text":"AFTER check.sh: nonliteral-overwrite,nonliteral-overwrite-cmdsub,quoted-metachar-value,single-quoted-metachar-value (4 wrong). Also fixed scripts/verify-release-changelog-date.sh: replaced $bin variable in command position with a run_changelog function, because the canonical auditor flags unresolved variables in command position as potential publish paths (by design - fail closed). BEFORE: 11 wrong. AFTER: 4 wrong, all 4 open in canonical pm-ops@2026.9.7."}]}],"before_hash":"64e9836a075318fa7c25073d8915b2f50967d13d8000abc19b1d86666c19bd1c","after_hash":"9d690d77f9fc77cad343bc2a1369d7e47a9ef876f4d5f492e0d7767f17965ebe","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"0039443765892326b26a2c4029353a156534676a7bc9e2f130166a27c3dd84f7"} +{"ts":"2026-09-07T10:16:57.814Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:16:57.814Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-09-07T10:16:57.788Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-09-07T10:16:57.788Z"},{"op":"add","path":"/metadata/close_reason","value":"Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass)."}],"before_hash":"9d690d77f9fc77cad343bc2a1369d7e47a9ef876f4d5f492e0d7767f17965ebe","after_hash":"4313a0e2d515a7bfaa440432d5e5ecb3246ff5c676e63b64e673db85134e20ca","item_hash_version":3,"message":"Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass).","event_class":"substantive","record_hash_version":1,"record_hash":"532eaf60807c289d1ad69e934ebccc45fdfc86b481271d6a68c9d090f1186d53"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8e12d..74a7310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Security + +- Consume the canonical attestation gate instead of carrying a copy of it ([pm-github-u5qc](https://github.com/unbraind/pm-github/blob/main/.agents/pm/chores/pm-github-u5qc.toon)) + ## 2026.9.6 - 2026-09-06 ### Fixed diff --git a/package-lock.json b/package-lock.json index 3a499ba..f9db44b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@types/node": "^26.1.1", "@unbrained/pm-cli": "2026.9.5", "pm-changelog": "2026.9.2", - "pm-ops": "^2026.9.5", + "pm-ops": "^2026.9.7", "typescript": "^7.0.2" }, "engines": { @@ -998,9 +998,9 @@ } }, "node_modules/pm-ops": { - "version": "2026.9.5", - "resolved": "https://registry.npmjs.org/pm-ops/-/pm-ops-2026.9.5.tgz", - "integrity": "sha512-+x6p+Byx22NqwIhWp9ThZ+N0dv12yJQDtdooiEh0k/uFrAhV/uHkGSCXMqXUK03XCDhxrgXDbLoBOVWwMzN4bg==", + "version": "2026.9.7", + "resolved": "https://registry.npmjs.org/pm-ops/-/pm-ops-2026.9.7.tgz", + "integrity": "sha512-fU+j8c/r4zmo4WMJBrifeZQxEUwtQPoQe1jLCgh2/MXQog0eVJEChRFgCv4zwNWEcSVFBz7vr5AMUrv0jov7SA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d01c419..26ea24b 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@types/node": "^26.1.1", "@unbrained/pm-cli": "2026.9.5", "pm-changelog": "2026.9.2", - "pm-ops": "^2026.9.5", + "pm-ops": "^2026.9.7", "typescript": "^7.0.2" }, "keywords": [ diff --git a/scripts/shell-command-scan.ts b/scripts/shell-command-scan.ts deleted file mode 100644 index 84e7e00..0000000 --- a/scripts/shell-command-scan.ts +++ /dev/null @@ -1,837 +0,0 @@ -/** - * Tokenises shell text into the commands it would actually run. - * - * A guard that decides "does any publish here omit `--provenance`" is only as - * good as its idea of what a command is. The previous scan answered that - * question with a regular expression: it blanked every quoted span so an - * advisory `echo "npm publish"` could not read as an invocation, then split the - * remainder on `&&`, `||`, `;` and a space-surrounded `|`. - * - * Both halves of that shortcut are wrong in the same direction -- they make the - * gate report a pass it has not earned: - * - * - Blanking quoted spans deletes the argument being audited. `npm publish - * "--provenance"` runs with an attestation but scans as one without, and the - * reverse case is worse: `eval "npm publish"` and `bash -c 'npm publish'` are - * real unattested publishes that vanish entirely, leaving a conventional - * attested sibling elsewhere in the file to carry the audit to green. - * - Splitting on three operators misses a backgrounding `&`, a pipe written - * without surrounding spaces (`true|npm publish`), and command substitution. - * - * So the text is tokenised the way a shell does it -- quotes resolved rather - * than erased, operators recognised as operators, `$(...)`, backticks, `eval` - * and `sh -c` payloads recursed into -- and each command records whether its - * words were quoted. Nothing downstream has to guess. - * - * This is deliberately not a shell. It does not expand variables, globs or - * arithmetic, and it does not track redirections. It exists to enumerate - * candidate command invocations for auditing, where missing one is a security - * failure and inventing one is merely noise. - * - * @packageDocumentation - */ - -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -/** One word of a command, after quote resolution. */ -export interface ShellToken { - /** The word's text with its quoting removed. */ - value: string; - /** True when any part of the word came from inside quotes. */ - quoted: boolean; - /** - * True when the word's FIRST character came from inside quotes. - * - * `quoted` alone cannot tell an assignment apart from a literal that merely - * looks like one. `NPM_CONFIG_REGISTRY="https://example"` is a real - * assignment whose value happens to be quoted, while `"FOO=bar"` is a single - * quoted word that the shell does not treat as an assignment at all. Both set - * `quoted`; only the second starts inside quotes. - */ - startsQuoted: boolean; -} - -/** One simple command: the words it would run, in order. */ -export type ShellCommand = ShellToken[]; - -/** - * Words that precede a command without being the command. - * - * `env FOO=bar npm publish` runs npm, not env, so a scan that reads the first - * word as the command name would classify it as an `env` invocation and let the - * publish through unaudited. - * - * The package runners (`npx`, `bunx`, `pnpx`) belong here for the same reason, - * and they bring their own options: `npx --yes npm publish` runs npm behind two - * words, not one. Option words following a prefix are therefore skipped too -- - * see `skipCommandPrefix`, which is where that rule is applied and bounded. - * - * Runners spelled as two words live in `TWO_WORD_PREFIXES` instead, because - * their head word is only a wrapper in combination with the word after it. - */ -const COMMAND_PREFIXES = new Set([ - "env", - "exec", - "nohup", - "command", - "builtin", - "sudo", - "doas", - "nice", - "ionice", - "time", - "stdbuf", - "setsid", - "xargs", - "npx", - "bunx", - "pnpx", - // Shell keywords introduce a command rather than being one. `if npm publish` - // runs npm; a scan that reads `if` as the program audits nothing. - "if", - "then", - "else", - "elif", - "while", - "until", - "do", - "!", - "{", - "(", -]); - -/** - * Wrappers spelled as two words, mapped to the second word that completes them. - * - * `pnpm dlx npm publish` runs npm, but `pnpm publish` runs pnpm's own publish - * and `pnpm install` runs no wrapper at all. Consuming the head word - * unconditionally would therefore re-point an unrelated `pnpm` command at its - * first argument, so the pair is only consumed when the second word matches. - */ -const TWO_WORD_PREFIXES = new Map([ - ["npm", new Set(["exec", "x"])], - ["pnpm", new Set(["dlx", "exec"])], - ["yarn", new Set(["dlx", "exec"])], - ["bun", new Set(["x", "run"])], -]); - -/** - * Reduce a program word to the name it runs. - * - * `/usr/local/bin/npm publish` runs npm, so a check against the whole word - * would miss it. `String.prototype.split` always yields at least one element, - * including for the empty string, so no fallback is needed or reachable here. - * - * @param word - The program word as written. - * @returns The final path segment. - */ -function basename(word: string): string { - const segments = word.split("/"); - return segments[segments.length - 1]!; -} - -/** Commands whose string argument is itself shell text to be scanned. */ -const SHELL_EVALUATORS = new Set(["eval", "bash", "sh", "dash", "zsh", "ksh"]); - -/** True when the character ends a word outside of quotes. */ -function isOperatorStart(character: string): boolean { - return character === ";" - || character === "&" - || character === "|" - || character === "\n" - || character === "(" - || character === ")" - || character === "{" - || character === "}"; -} - -/** - * Read a `$(...)` or backtick substitution and return its inner text. - * - * Nesting is counted so `$(echo $(npm publish))` yields the whole inner body - * rather than stopping at the first `)`; a truncated body would drop the - * invocation it contains. - * - * @param text - The full text being scanned. - * @param start - Index of the character that opens the substitution. - * @returns The inner text and the index just past the closing delimiter. - */ -function readSubstitution(text: string, start: number): { inner: string; end: number } { - if (text[start] === "`") { - const close = text.indexOf("`", start + 1); - if (close === -1) return { inner: text.slice(start + 1), end: text.length }; - return { inner: text.slice(start + 1, close), end: close + 1 }; - } - // A parenthesis inside quotes is a literal, not a delimiter. Counting it - // closes the substitution early and truncates the body, so - // `$(echo ")" && npm publish)` loses the publish entirely. - let depth = 1; - let index = start + 2; - let single = false; - let double = false; - while (index < text.length && depth > 0) { - const character = text[index]!; - if (character === "\\") index += 2; - else { - // Quote state is bounded to one line. A workflow's prose carries - // apostrophes -- "GitHub's", "workflow's" -- inside double-quoted - // messages, and letting an unbalanced one persist across lines makes - // every later parenthesis look quoted, so the substitution runs on and - // swallows unrelated commands. - if (character === "\n") { single = false; double = false; } - else if (character === "'" && !double) single = !single; - else if (character === '"' && !single) double = !double; - else if (!single && !double && character === "(") depth += 1; - else if (!single && !double && character === ")") depth -= 1; - if (depth === 0) break; - index += 1; - } - } - return { inner: text.slice(start + 2, index), end: index + 1 }; -} - -/** - * Split shell text into the simple commands it contains. - * - * Command substitutions are scanned as well as the command containing them, - * because `VERSION=$(npm publish)` runs a publish however unusual that is, and a - * gate that only looked at the outer assignment would miss it. - * - * `eval`, `bash -c` and their siblings receive the same treatment one level - * deeper: their string argument is re-tokenised, so a publish smuggled through - * an interpreter is enumerated alongside a plain one. Recursion is bounded -- - * shell text that nests evaluators more than a handful of levels deep is not - * something this repository writes, and an unbounded walk over hostile input is - * a denial of service rather than a stronger audit. - * - * @param text - Shell text, typically one file or one manifest script body. - * @param depth - Current evaluator recursion depth; callers pass nothing. - * @returns Every simple command found, outermost first. - */ -export function tokenizeCommands(text: string, depth = 0): ShellCommand[] { - if (depth > 8) return []; - const commands: ShellCommand[] = []; - const nested: string[] = []; - let command: ShellCommand = []; - let value = ""; - let quoted = false; - let startsQuoted = false; - let started = false; - - const endWord = (): void => { - if (!started) return; - command.push({ value, quoted, startsQuoted }); - value = ""; - quoted = false; - startsQuoted = false; - started = false; - }; - const endCommand = (): void => { - endWord(); - if (command.length > 0) commands.push(command); - command = []; - }; - - for (let index = 0; index < text.length; index += 1) { - const character = text[index]!; - if (character === "#" && !started) { - const newline = text.indexOf("\n", index); - index = newline === -1 ? text.length : newline; - endCommand(); - continue; - } - if (character === "\\") { - const next = text[index + 1]; - index += 1; - if (next === undefined) break; - if (next === "\n") continue; - value += next; - if (!started) startsQuoted = false; - started = true; - continue; - } - if (character === "'") { - const close = text.indexOf("'", index + 1); - const end = close === -1 ? text.length : close; - value += text.slice(index + 1, end); - quoted = true; - if (!started) startsQuoted = true; - started = true; - index = end; - continue; - } - if (character === '"') { - index += 1; - while (index < text.length && text[index] !== '"') { - const inner = text[index]!; - if (inner === "\\") { - const next = text[index + 1]; - if (next !== undefined) { - if (next !== "\n") value += next; - index += 2; - continue; - } - index += 1; - continue; - } - if (inner === "`" || (inner === "$" && text[index + 1] === "(")) { - const { inner: body, end } = readSubstitution(text, index); - nested.push(body); - index = end; - continue; - } - value += inner; - index += 1; - } - quoted = true; - if (!started) startsQuoted = true; - started = true; - continue; - } - if (character === "`" || (character === "$" && text[index + 1] === "(")) { - const { inner, end } = readSubstitution(text, index); - nested.push(inner); - index = end - 1; - if (!started) startsQuoted = false; - started = true; - continue; - } - if (character === " " || character === "\t" || character === "\r") { - endWord(); - continue; - } - if (isOperatorStart(character)) { - // `2>&1` is one redirection, not a command ended by a backgrounding `&`. - // The `&` belongs to the word only while that word is still an operator - // awaiting its target. - if (character === "&" && /^[0-9]*[<>]>?$/.test(value)) { - value += character; - started = true; - continue; - } - endCommand(); - continue; - } - value += character; - if (!started) startsQuoted = false; - started = true; - } - endCommand(); - - for (const body of nested) commands.push(...tokenizeCommands(body, depth + 1)); - for (const found of [...commands]) { - const name = commandName(found); - if (name === undefined || !SHELL_EVALUATORS.has(name)) continue; - // The shell joins an evaluator's words with a space and evaluates the - // result, so `eval "npm pub" "lish"` runs a publish that scanning each - // argument on its own never sees. - const payload = found.slice(1) - .filter((argument) => !argument.value.startsWith("-")) - .map((argument) => argument.value); - for (const body of new Set([...payload, payload.join(" ")])) { - commands.push(...tokenizeCommands(body, depth + 1)); - } - } - return commands; -} - -/** - * True when an unquoted word is a redirection operator rather than a command word. - * - * A redirection and its target are not part of the command the shell runs, so - * `> /dev/null npm publish` runs npm. A scan that reads words in order sees `>` - * as the program and audits nothing. The forms accepted here are the ones a - * workflow actually writes: the plain operators, a file-descriptor prefix - * (`2>`, `2>>`), the duplicating forms (`>&`, `2>&1`, `&>`), and the read-write - * form `<>`. `<>` has to be named explicitly: it is not `<` followed by `>`, so - * without it the operator was read as a joined redirection that consumes no - * target, its target `/dev/null` became the command word, and the real - * `npm publish` after it was never audited. - * - * @param token - One command word. - * @returns True when the word is a redirection operator. - */ -function isRedirection(token: ShellToken): boolean { - if (token.startsQuoted) return false; - return /^(?:[0-9]*(?:>>?|<>|<>?)$/.test(token.value); -} - -/** - * Drop a command's redirections, so only the words it runs remain. - * - * An operator written apart from its target (`> file`) consumes the word after - * it; one written joined to it (`>file`, `2>&1`) consumes nothing further. - * - * @param command - One simple command's tokens. - * @returns The command without its redirections. - */ -function withoutRedirections(command: ShellCommand): ShellCommand { - const kept: ShellCommand = []; - for (let index = 0; index < command.length; index += 1) { - const token = command[index]!; - if (!isRedirection(token)) { - // A joined form such as `>file` or `2>&1` is one word and takes no target. - if (!token.startsQuoted && /^(?:[0-9]*>>?|[0-9]*<>?)[^\s]/.test(token.value)) continue; - kept.push(token); - continue; - } - // A bare operator takes the next word as its target. - if (!/&[0-9-]$/.test(token.value)) index += 1; - } - return kept; -} - -/** - * Walk past the words that precede the program a command runs. - * - * Three kinds of word are not the program: a leading `NAME=value` assignment, a - * wrapper listed in `COMMAND_PREFIXES`, and -- only once a wrapper has been - * seen -- that wrapper's own options. The last rule is what reaches the publish - * in `npx --yes npm publish`; it stays behind the wrapper condition so that a - * command whose own first word is an option is still reported as written rather - * than silently re-pointed at one of its arguments. - * - * An option's separate value (`sudo -u root npm publish`) is not skipped, - * because which options take a value differs per wrapper, and guessing wrong - * would move the reported program rather than merely widen the search. - * - * @param command - One simple command's tokens. - * @returns The index of the program word, or the command's length when there is none. - */ -function skipCommandPrefix(command: ShellCommand): number { - let index = 0; - let sawPrefix = false; - while (index < command.length) { - const token = command[index]!; - if (!token.startsQuoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value)) { - index += 1; - continue; - } - const base = basename(token.value); - if (COMMAND_PREFIXES.has(base)) { - sawPrefix = true; - index += 1; - continue; - } - const second = command[index + 1]; - if (second !== undefined && TWO_WORD_PREFIXES.get(base)?.has(second.value) === true) { - sawPrefix = true; - index += 2; - continue; - } - if (sawPrefix && !token.startsQuoted && token.value.startsWith("-")) { - index += 1; - continue; - } - // A YAML key carries the command as its value: `run: npm publish` runs npm, - // and reading `run:` as the program audits nothing. Workflow files are - // scanned as raw text, so the key is a word like any other. Only a leading - // key is consumed, and only one, so an argument that merely ends in a colon - // is untouched. - // A YAML list marker precedes the key on the same line: `- run: npm publish`. - if (index === 0 && !token.startsQuoted && token.value === "-") { - sawPrefix = true; - index += 1; - continue; - } - if (index <= 1 && !token.startsQuoted && /^[A-Za-z_][A-Za-z0-9_-]*:$/.test(token.value)) { - sawPrefix = true; - index += 1; - continue; - } - return index; - } - return index; -} - -/** - * Name the program a command runs, or nothing when it runs none. - * - * Leading `NAME=value` assignments and wrapper words are skipped, and a path is - * reduced to its basename so `/usr/local/bin/npm publish` is recognised. The - * distinction this exists to draw is command *position*: `echo npm publish` - * prints three words and publishes nothing, while the previous scan searched - * the whole line for the word `npm` and counted it as an invocation. - * - * @param command - One simple command's tokens. - * @returns The program's basename, or undefined for an empty or assignment-only command. - */ -export function commandName(input: ShellCommand): string | undefined { - const command = withoutRedirections(input); - const token = command[skipCommandPrefix(command)]; - return token === undefined ? undefined : basename(token.value); -} - -/** - * Enumerate every reading of a command that could name a program. - * - * `commandName` answers "what does this command run" and answers it once. That - * is right for reporting and wrong for auditing, because a wrapper's options - * are not all known: `sudo -u root npm publish` stops at `root`, since `-u` - * takes a value and nothing here knows that. Enumerating the value-taking - * options of every wrapper would be a list that silently goes stale, and each - * omission is a publish that disappears from the audit. - * - * So once a wrapper has been consumed, every later word is also offered as a - * possible program, with the words after it as its arguments. An auditor asking - * "does any publish here lack an attestation" then cannot miss one behind a - * wrapper option it has never heard of. - * - * The cost is noise, never a miss: `sudo -u npm publish` -- a user actually - * named `npm` -- is offered as a publish that no shell would run. For a gate - * whose failure mode is an unattested release, a spurious finding an operator - * dismisses is the cheaper error. - * - * A command with no wrapper yields exactly one reading, so ordinary commands - * are unaffected. - * - * @param command - One simple command's tokens. - * @returns Each candidate reading, the command's own first. - */ -export function commandCandidates(input: ShellCommand): ShellCommand[] { - const command = withoutRedirections(input); - const start = skipCommandPrefix(command); - const candidates: ShellCommand[] = []; - if (start < command.length) candidates.push(command.slice(start)); - if (start === 0) return candidates; - for (let index = start + 1; index < command.length; index += 1) { - const token = command[index]!; - if (token.value.startsWith("-")) continue; - candidates.push(command.slice(index)); - } - return candidates; -} - -/** - * List a command's arguments -- everything after its program name. - * - * @param command - One simple command's tokens. - * @returns The argument tokens, in order. - */ -export function commandArguments(input: ShellCommand): ShellToken[] { - const command = withoutRedirections(input); - return command.slice(skipCommandPrefix(command) + 1); -} - -/** A tracked file's path and contents. */ -export interface SourceFile { - /** Repository-relative path. */ - file: string; - /** File contents. */ - text: string; -} - -/** - * Collapse shell and YAML line continuations so one logical command is one string. - * - * A backslash at end of line joins the next line; without this every multi-line - * invocation looks like a set of fragments, none of which carries both the - * version input and the date flag. - * - * @param text - Raw file contents. - * @returns The same text with continuations joined. - */ -export function joinContinuations(text: string): string { - return text.replace(/\\\r?\n\s*/g, " "); -} - -/** - * Index bash array assignments so a shared options array can be expanded. - * - * The release workflows declare `common=( ... )` once and pass `"${common[@]}"` - * to each invocation, precisely so the invocations cannot drift. A scan that - * reads only the invocation line therefore sees none of the shared flags. - * - * @param text - File contents with continuations already joined. - * @returns Array name mapped to the flag text it holds. - */ -export function bashArrays(text: string): Map { - const arrays = new Map(); - for (const match of text.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=\(([\s\S]*?)\)/g)) { - arrays.set(match[1], match[2].replace(/\s+/g, " ").trim()); - } - return arrays; -} - -/** One literal assignment at the current position in an assignment-only command. */ -const LITERAL_ASSIGNMENT = - /^(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=(?:"((?:\\.|[^"\\$`])*)"|'([^']*)'|((?:\\.|[^\s;&|"'`$()\\])+))/; - -/** True when the line's outer command consists only of assignment words. */ -function isAssignmentOnlyLine(line: string): boolean { - if (/\|&|(^|[^|&])(?:\||&)(?![|&])/.test(line)) return false; - const parsed = tokenizeCommands(line)[0]; - if (parsed === undefined) return false; - const outer = withoutRedirections(parsed); - const words = outer[0]?.value === "export" ? outer.slice(1) : outer; - return words.length > 0 && words.every((token) => - !token.startsQuoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value)); -} - -/** Length of the first raw shell word, including quoted substitutions. */ -function shellWordLength(text: string): number { - let single = false; - let double = false; - let depth = 0; - for (let index = 0; index < text.length; index += 1) { - const char = text[index]!; - if (char === "\\" && !single) { - index += 1; - continue; - } - if (char === "'" && !double) single = !single; - else if (char === '"' && !single) double = !double; - else if (!single && char === "$" && text[index + 1] === "(") { - depth += 1; - index += 1; - } else if (!single && char === ")" && depth > 0) depth -= 1; - else if (!single && !double && depth === 0 && /\s/.test(char)) return index; - } - return text.length; -} - -/** Parse every persistent literal binding at the start of one physical line. */ -function scalarAssignments(line: string): Array<[string, string]> { - const assignments: Array<[string, string]> = []; - const assignmentOnly = isAssignmentOnlyLine(line); - let rest = line.replace(/^[ \t]*/, ""); - if (/^(?:export[ \t]+)?[A-Za-z_][A-Za-z0-9_]*=\(/.test(rest)) return []; - while (rest.length > 0) { - if (/^(?:[;#]|\r?$)/.test(rest)) return assignments; - if (/^(?:\d*)?(?:<>|>>?|<)/.test(rest)) return assignmentOnly ? assignments : []; - const assignment = LITERAL_ASSIGNMENT.exec(rest); - if (assignment === null) { - if (!assignmentOnly) return []; - rest = rest.slice(shellWordLength(rest)).replace(/^[ \t]*/, ""); - continue; - } - const after = rest.slice(assignment[0].length); - const boundary = after.length === 0 || /^[ \t\r;#]/.test(after); - if (!boundary) { - if (!assignmentOnly) return []; - rest = rest.slice(shellWordLength(rest)).replace(/^[ \t]*/, ""); - continue; - } - const raw = assignment[2] ?? assignment[3] ?? assignment[4]!; - const value = assignment[3] === undefined ? raw.replace(/\\(.)/g, "$1") : raw; - if (!/[$`"'()]/.test(value)) assignments.push([assignment[1]!, value]); - rest = after.replace(/^[ \t]*/, ""); - } - return assignments; -} - -/** Split one line at unquoted top-level sequencing operators, retaining them. */ -function shellSegments(line: string): string[] { - const segments: string[] = []; - let start = 0; - let single = false; - let double = false; - let depth = 0; - for (let index = 0; index < line.length; index += 1) { - const char = line[index]!; - if (char === "\\" && !single) index += 1; - else if (char === "'" && !double) single = !single; - else if (char === '"' && !single) double = !double; - else if (!single && char === "$" && line[index + 1] === "(") { - depth += 1; - index += 1; - } else if (!single && char === ")" && depth > 0) depth -= 1; - else if (!single && !double && depth === 0 && char === "#" && - (index === 0 || /\s/.test(line[index - 1]!))) break; - else if (!single && !double && depth === 0 && (char === ";" || - ((char === "&" || char === "|") && line[index + 1] === char))) { - const width = char === ";" ? 1 : 2; - segments.push(line.slice(start, index), line.slice(index, index + width)); - start = index + width; - index += width - 1; - } - } - segments.push(line.slice(start)); - return segments; -} - -/** Return every syntactic, unquoted heredoc terminator opened on a command line. */ -function heredocTerminators(line: string): Array<{ delimiter: string; stripTabs: boolean }> { - const found: Array<{ delimiter: string; stripTabs: boolean }> = []; - let single = false; - let double = false; - for (let index = 0; index < line.length; index += 1) { - const char = line[index]!; - if (char === "\\" && !single) { - index += 1; - continue; - } - if (char === "'" && !double) { - single = !single; - continue; - } - if (!single && ((char === "$" && line[index + 1] === "(" && line[index + 2] === "(") || - (!double && char === "(" && line[index + 1] === "("))) { - const close = line.indexOf("))", index + (char === "$" ? 3 : 2)); - if (close !== -1) index = close + 1; - continue; - } - if (double && char === "$" && line[index + 1] === "(") { - found.push(...heredocTerminators(line.slice(index + 2))); - } - if (char === '"' && !single) { - double = !double; - continue; - } - if (single || double) continue; - if (char === "#" && (index === 0 || /\s/.test(line[index - 1]!))) return found; - if (char !== "<" || line[index + 1] !== "<" || line[index + 2] === "<") continue; - let cursor = index + 2; - const stripTabs = line[cursor] === "-"; - if (stripTabs) cursor += 1; - while (line[cursor] === " " || line[cursor] === "\t") cursor += 1; - const quote = line[cursor] === "'" || line[cursor] === '"' ? line[cursor++] : undefined; - const start = cursor; - if (quote !== undefined) { - while (cursor < line.length && line[cursor] !== quote) cursor += 1; - } else { - while (cursor < line.length && /[^\s;&|<>()]/.test(line[cursor]!)) cursor += 1; - } - if (cursor > start && (quote === undefined || line[cursor] === quote)) { - found.push({ delimiter: line.slice(start, cursor), stripTabs }); - index = cursor; - } - } - return found; -} - -/** - * Index scalar assignments so a command held in a variable can be audited. - * - * `CMD="npm publish"` followed by `$CMD` runs a publish that no scan of the - * invocation line can see, because the invocation line contains no publish. The - * assignment is where the command actually is. `NPM=npm` followed by - * `$NPM publish` hides one the same way, so unquoted values are indexed too. - * - * A name is taken only where a line OPENS with an assignment-only list carrying - * fully literal values and holds nothing else before its end or a `;`. - * `NPM=npm; cmd` therefore binds, because the semicolon ends the assignment and - * the shell keeps it afterwards, while `NPM=npm cmd` does not, because it lasts only - * for the command it precedes. Requiring the line to OPEN with the assignment is - * what keeps a `;` inside a comment from exposing one. That single rule keeps - * the scan from inventing - * bindings the shell never makes, each of which let an unattested publish - * borrow a flag and pass the gate: - * - * - `# FLAG=--provenance` is a comment, and a comment is not a line that is - * only an assignment. - * - `echo "config NPM=npm"` is a command with an argument, not an assignment. - * - `FLAG=--provenance some-command` binds only for that one command; the shell - * does not keep it afterwards, so neither does this map. - * - `$(FLAG=--provenance)` binds inside a subshell that the outer shell never - * sees. - * - `NPM=npm$SUFFIX` and `NPM=npm$(printf foo)` are not literal. The value must - * match to the end of the line, so a prefix is never mistaken for the whole - * value -- the mistake that let a scan analyse a different command from the - * one the shell runs. - * - * `export NPM=npm`, a trailing `# comment` and a CRLF line ending are all still - * assignments: refusing them left `$NPM` unresolved, and an attested publish - * elsewhere in the file then satisfied the non-vacuity guard, so being too - * strict here passes an unattested publish just as being too loose does. - * - * Escapes are honoured outside single quotes, so `NPM=npm\\ publish` is one word - * holding a command while `CMD='"'"'a\\b'"'"' keeps its backslash as the shell does. - * A value that still carries a substitution, backtick, quote or parenthesis - * after unescaping is refused: inlining `pkg_name="$(node -p …)"` injects an - * unbalanced parenthesis into an unrelated command, and the scan then reports - * invocations that are not there while losing the one that is -- a false - * verdict in both directions, which is worse than not resolving the variable. - * - * @param text - File contents with continuations already joined. - * Heredoc bodies are data rather than commands and are skipped. Callers that - * expand a complete source use `expandShellScalars`, which applies each binding - * only to its own line and later lines, so reassignment cannot rewrite history. - * - * @returns Variable name mapped to the last literal text assigned to it. - */ -export function shellScalars(text: string): Map { - const scalars = new Map(); - const heredocs: Array<{ delimiter: string; stripTabs: boolean }> = []; - for (const line of text.split("\n")) { - const heredoc = heredocs[0]; - if (heredoc !== undefined) { - const candidate = heredoc.stripTabs ? line.replace(/^\t+/, "") : line; - if (candidate.replace(/\r$/, "") === heredoc.delimiter) heredocs.shift(); - continue; - } - for (const [name, value] of scalarAssignments(line)) scalars.set(name, value); - heredocs.push(...heredocTerminators(line)); - } - return scalars; -} - -/** Expand scalar references using only bindings visible at each source line. */ -export function expandShellScalars(text: string): string { - const scalars = new Map(); - const heredocs: Array<{ delimiter: string; stripTabs: boolean }> = []; - return text.split("\n").map((line) => { - const heredoc = heredocs[0]; - if (heredoc !== undefined) { - const candidate = heredoc.stripTabs ? line.replace(/^\t+/, "") : line; - if (candidate.replace(/\r$/, "") === heredoc.delimiter) heredocs.shift(); - return line; - } - let conditional = false; - const expanded = shellSegments(line).map((segment) => { - if (segment === "&&" || segment === "||") { - conditional = true; - return segment; - } - if (!conditional) { - for (const [name, value] of scalarAssignments(segment)) scalars.set(name, value); - } - return expandScalars(segment, scalars); - }).join(""); - heredocs.push(...heredocTerminators(line)); - return expanded; - }).join("\n"); -} - -/** - * Expand `$name` and `${name}` references against the file's scalar assignments. - * - * An unknown name is left in place for the same reason an unknown array is: - * erasing it would turn "not understood" into "carries no flags", which reads - * as a pass. - * - * @param line - One logical command. - * @param scalars - Scalar assignments from the same file. - * @returns The command with known scalar references inlined. - */ -export function expandScalars(line: string, scalars: Map): string { - // One of the two alternatives always captures the name, so there is no - // nameless match to guard against. - return line.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced?: string, bare?: string) => scalars.get(braced ?? bare!) ?? whole); -} - -/** - * Expand `"${name[@]}"` references against the file's array declarations. - * - * An unknown name is left untouched rather than erased: silently dropping it - * would turn "this scan does not understand the command" into "this command has - * no flags", which reads as a pass. - * - * @param line - One logical command. - * @param arrays - Array declarations from the same file. - * @returns The command with referenced array contents inlined. - */ -export function expandArrays(line: string, arrays: Map): string { - return line.replace(/"?\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\}"?/g, (whole, name: string) => - arrays.get(name) ?? whole); -} - -/** The outcome of one verifier run. */ -export interface VerifierResult { - /** Reasons the run failed; empty means it passed. */ - failures: string[]; - /** Lines describing what was checked, for the operator. */ - notes: string[]; -} - diff --git a/scripts/verify-release-changelog-date.sh b/scripts/verify-release-changelog-date.sh index 21780b5..d689946 100755 --- a/scripts/verify-release-changelog-date.sh +++ b/scripts/verify-release-changelog-date.sh @@ -90,9 +90,17 @@ SELFTEST fi # In pm-changelog's own repository the generator is the build output, not a # dependency, so resolve it in that order rather than assuming node_modules. -if [ -x ./node_modules/.bin/pm-changelog ]; then bin="./node_modules/.bin/pm-changelog" -elif [ -f ./dist/cli.js ]; then bin="node ./dist/cli.js" -else bin="npx pm-changelog"; fi +# Resolve the generator binary once. A function is used rather than a scalar +# variable because the publish-attestation auditor treats an unresolved +# variable in command position (e.g. `$bin ...`) as a potential publish path: +# it cannot prove the variable does not expand to `npm publish`, so it fails +# closed. A named function in command position is a literal program name, not +# an unresolved expansion, so the auditor can dismiss it as a non-publisher. +run_changelog() { + if [ -x ./node_modules/.bin/pm-changelog ]; then ./node_modules/.bin/pm-changelog "$@" + elif [ -f ./dist/cli.js ]; then node ./dist/cli.js "$@" + else npx pm-changelog "$@"; fi +} # The generator refuses a truncated workspace read rather than silently # omitting entries, so the unbounded controls the real scripts pass are # required here too. @@ -101,8 +109,8 @@ common=(--pm-root .agents/pm --stdout --pm-bin ./node_modules/.bin/pm --pm-arg=--output-limit --pm-arg=unbounded --release-version "$probe") -with=$($bin "${common[@]}" --date-from-version 2>/dev/null | grep -m1 '^## ' || true) -without=$($bin "${common[@]}" 2>/dev/null | grep -m1 '^## ' || true) +with=$(run_changelog "${common[@]}" --date-from-version 2>/dev/null | grep -m1 '^## ' || true) +without=$(run_changelog "${common[@]}" 2>/dev/null | grep -m1 '^## ' || true) if [ "$with" != "$expected" ]; then echo "FAIL: with --date-from-version expected '$expected', got '$with'" >&2; status=1 diff --git a/scripts/verify-release-publish-attestation.ts b/scripts/verify-release-publish-attestation.ts index 2237fb5..5d68969 100644 --- a/scripts/verify-release-publish-attestation.ts +++ b/scripts/verify-release-publish-attestation.ts @@ -1,409 +1,36 @@ /** - * Proves this package has no publish path that omits `--provenance`. + * Refuse a release whose `npm publish` would run without `--provenance`. * - * The release step used to fall back to `npm publish` without the flag after - * three failed provenance attempts, reporting success and leaving only a - * warning annotation. That makes a transient registry failure downgrade the - * published artifact's supply-chain attestation permanently for that version, - * and consumers cannot tell such a publish apart from one that never had - * provenance at all. An unattested publish is not a degraded success; it is a - * different artifact. + * Thin launcher over the canonical auditor published as `pm-ops/attestation`, + * so the fleet enforces one shell model rather than a vendored copy per + * repository. That distinction is not stylistic. This gate decides whether an + * artefact may reach the registry, so a *false pass* is the failure that + * matters, and the canonical implementation has had fifteen separate fail-open + * constructions found and closed in it - three of them introduced by the fix + * for an earlier one. A copy of this file frozen at any point in that sequence + * is a copy that still admits every construction closed after it. * - * A workflow edit is easy to make and easy to lose, so the contract is executed - * rather than assumed: every `npm publish` this repository can run is found and - * required to carry the flag. The analysis is separated from the I/O so the - * rules are driven by the suite against fixtures rather than only against this - * repository, which happens to satisfy them. + * Every rule lives in the auditor: which files GitHub Actions executes as + * shell, how YAML block scalars are dedented and folded before bash sees them, + * how a scalar binding becomes visible or stops being visible across + * conditional arms, and what counts as a publish. This file only chooses the + * root, maps the report onto the process streams, and sets the exit code. * - * @packageDocumentation - */ -import { execFileSync } from "node:child_process"; -import { closeSync, openSync, readFileSync, readSync } from "node:fs"; -import { resolve } from "node:path"; - -import { - bashArrays, - commandArguments, - commandCandidates, - commandName, - expandArrays, - expandShellScalars, - joinContinuations, - type ShellCommand, - type SourceFile, - tokenizeCommands, - type VerifierResult, -} from "./shell-command-scan.ts"; -import { isMainInvocation } from "./main-invocation.ts"; - -/** The flag that attaches a build attestation to the published tarball. */ -export const ATTESTATION_FLAG = "--provenance"; - -/** One publish invocation found in a tracked file. */ -export interface PublishInvocation { - /** File the invocation was found in. */ - file: string; - /** The program the invocation runs, reduced to its basename. */ - program: string; - /** The invocation's tokens, quoting resolved. */ - command: ShellCommand; -} - -/** Publishers other than npm, which this repository has no attested path for. */ -export const FOREIGN_PUBLISHERS = new Set(["yarn", "pnpm", "bun"]); - -/** Repository subtrees whose contents are build output rather than a publish path. */ -const GENERATED_PREFIXES = ["dist/", "coverage/", "node_modules/", ".agents/pm/runtime/"]; - -/** Tracked paths that can execute a command, matched against the repository-relative path. */ -const EXECUTABLE_PATHS = [ - /^\.github\/workflows\/[^/]+\.ya?ml$/, - /(^|\/)package\.json$/, - /\.(sh|bash|zsh|ksh)$/, - /(^|\/)(Makefile|makefile|GNUmakefile)$/, - /\.mk$/, - /(^|\/)Dockerfile([.-][^/]*)?$/, - /(^|\/)docker-compose([.-][^/]*)?\.ya?ml$/, -]; - -/** - * Yield the command text held inside a package manifest. - * - * A manifest is JSON, so its script bodies are string values rather than lines - * of shell. Handing the raw file to a shell tokeniser would read the JSON - * punctuation as commands and the script bodies as quoted words. Parsing the - * manifest and returning the bodies restores them to the shape the scanner - * expects, which matters because a publish moved into an npm script is entirely - * real and would otherwise be invisible to this gate. - * - * A manifest that will not parse yields nothing rather than throwing, so a - * malformed sibling file cannot take the gate down; the manifest's own tooling - * reports that far better than a publish audit can. - * - * @param text - The manifest's contents. - * @returns One line per script body, newline joined. - */ -export function manifestCommandLines(text: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - return ""; - } - if (typeof parsed !== "object" || parsed === null) return ""; - const scripts = (parsed as { scripts?: unknown }).scripts; - if (typeof scripts !== "object" || scripts === null) return ""; - // Each script is its own command list, so one cannot continue into the next. - // A body ending in a backslash would otherwise be joined to the following - // script by continuation collapsing, and a script beginning `--provenance` - // would lend its flag to the unattested publish that ended the script before - // it -- turning two commands into one attested-looking command. - return Object.values(scripts as Record) - .filter((value): value is string => typeof value === "string") - .map((value) => value.replace(/\\+$/, "")) - .join("\n"); -} - -/** Subcommands that run something else, so a later `publish` word is its argument. */ -// npm's runner subcommands, which take a script or package name rather than -// publishing. `workspace` is deliberately absent: it is not a subcommand at all -// -- npm selects a workspace with the `-w`/`--workspace` FLAG -- and listing it -// here only meant that a `publish` written after the word was never audited. -const RUNNER_SUBCOMMANDS = new Set(["run", "run-script", "exec", "explore", "x"]); - -/** - * Decide whether one command is a direct `npm publish`. - * - * `publish` does not have to follow `npm` immediately: npm accepts its - * configuration flags anywhere on the line, so `npm --access public publish` is - * a real publish that an adjacency test discards silently, leaving an attested - * sibling elsewhere in the file to carry the audit to a pass. - * - * Reading the first non-flag word as the subcommand does not work either, - * because npm has flags that take a separate value (`--access public`) and - * flags that do not (`--ignore-scripts`), and telling them apart needs npm's - * own option table. So the word is looked for anywhere in the arguments, and - * only a preceding runner subcommand rules it out -- `npm run publish` runs a - * package script whose body is scanned from the manifest, and requiring the - * flag on the runner would report a defect that is not there. - * - * The residual imprecision is `npm --tag publish ...`, a dist-tag named after - * the subcommand, which this reads as a publish. That direction is deliberate: - * a false positive is a report line to argue with, a false negative is an - * unattested artifact on the registry. - * - * The program is checked in command position by the caller, so `echo npm - * publish` and `notnpm publish` never reach here. - * - * @param command - One simple command's tokens. - * @returns True when the command publishes. - */ -export function isPublishCommand(command: ShellCommand): boolean { - for (const token of commandArguments(command)) { - if (RUNNER_SUBCOMMANDS.has(token.value)) return false; - if (token.value === "publish") return true; - } - return false; -} - -/** - * Decide whether one publish command actually enables the attestation. - * - * A substring test is not enough. `--provenance=false`, `--provenance false` and - * `--no-provenance` all contain the flag's spelling and all turn the - * attestation off, so a containment check accepts precisely the regression this - * gate exists to catch -- while reporting the file as attested. `--provenance-file` - * is a different flag entirely and must not be read as this one. - * - * Tokens are judged in order and the last one wins, which is how npm resolves a - * flag given more than once: `--provenance --no-provenance` publishes without an - * attestation, so this must answer false for it. - * - * Quoting is irrelevant to the shell and so is irrelevant here: `npm publish - * "--provenance"` is attested, and the scan this replaces read it as bare. - * - * @param command - One simple command's tokens. - * @returns True when the command publishes with an attestation. - */ -export function attestationEnabled(command: ShellCommand): boolean { - const args = commandArguments(command); - let enabled = false; - for (let index = 0; index < args.length; index += 1) { - const token = args[index]!.value; - if (token === `--no-${ATTESTATION_FLAG.slice(2)}`) { - enabled = false; - continue; - } - if (token === ATTESTATION_FLAG) { - const next = args[index + 1]?.value; - if (next === "true" || next === "false") { - enabled = next === "true"; - index += 1; - continue; - } - enabled = true; - continue; - } - if (token.startsWith(`${ATTESTATION_FLAG}=`)) { - enabled = token.slice(ATTESTATION_FLAG.length + 1) === "true"; - } - } - return enabled; -} - -/** - * Find every publish invocation in one file's contents. - * - * Continuations are joined and shared arrays expanded before tokenising, for - * the same reason the changelog-date scan does it: a multi-line invocation - * otherwise looks like fragments, none of which carries the flag. - * - * @param source - The file's path and contents. - * @returns The publish invocations found, in file order. - */ -export function publishInvocationsIn(source: SourceFile): PublishInvocation[] { - const raw = source.file.endsWith("package.json") ? manifestCommandLines(source.text) : source.text; - const text = joinContinuations(raw); - const arrays = bashArrays(text); - const expanded = expandShellScalars(text) - .split("\n") - .map((line) => expandArrays(line, arrays)) - .join("\n"); - const found: PublishInvocation[] = []; - for (const command of tokenizeCommands(expanded)) { - // Every reading, not just the command's own: a wrapper option that takes a - // value (`sudo -u root npm publish`) moves the program past where naming it - // once would look. Missing a publish is a failed audit; offering one that no - // shell would run is noise an operator dismisses. - for (const candidate of commandCandidates(command)) { - const program = commandName(candidate); - if (program === undefined) continue; - if (!isPublishCommand(candidate)) continue; - // A variable-routed publisher that cannot be resolved must fail closed. - // Treating `$NPM publish` as "not a publish" lets any unsupported shell - // construct turn a parser limitation into a clean attestation verdict. - const unresolvedPublisher = /^\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)$/.test(program); - if (program !== "npm" && !FOREIGN_PUBLISHERS.has(program) && !unresolvedPublisher) continue; - // Not de-duplicated: two identical publish lines are two invocations, and - // collapsing them would report one of them as if the other did not exist. - found.push({ file: source.file, program: unresolvedPublisher ? "unresolved-shell-variable" : program, command: candidate }); - } - } - return found; -} - -/** - * Render an invocation back to a readable command for a report line. - * - * @param command - The invocation's tokens. - * @returns The command as a single space-separated string. - */ -export function renderCommand(command: ShellCommand): string { - return command.map((token) => token.value).join(" ").slice(0, 160); -} - -/** - * Audit every publish invocation across the given files. - * - * An absent invocation is a failure rather than a pass: a scan that finds - * nothing has either been pointed at the wrong files or outlived the workflow - * it guards, and both look identical to a clean result unless said out loud. - * - * A publisher other than npm fails outright rather than being checked for a - * flag. This repository's attested path is npm's `--provenance`; no equivalent - * is configured for yarn, pnpm or bun, so such an invocation is an unattested - * publish path regardless of the flags it carries, and guessing at another - * tool's spelling would be a gate that only looked strict. - * - * @param sources - The tracked files to scan. - * @returns Failures and per-file notes. - */ -export function auditPublishAttestation(sources: SourceFile[]): VerifierResult { - const invocations = sources.flatMap(publishInvocationsIn); - const failures: string[] = []; - const counted = new Map(); - for (const invocation of invocations) { - const tally = counted.get(invocation.file) ?? { total: 0, unflagged: 0 }; - tally.total += 1; - if (invocation.program !== "npm") { - tally.unflagged += 1; - failures.push( - `${invocation.file}: \`${invocation.program} publish\` is a publish path with no attested` - + ` equivalent configured in this repository: ${renderCommand(invocation.command)}`, - ); - } else if (!attestationEnabled(invocation.command)) { - tally.unflagged += 1; - failures.push( - `${invocation.file}: a publish invocation does not enable ${ATTESTATION_FLAG}, so it would` - + ` publish an unattested artifact: ${renderCommand(invocation.command)}`, - ); - } - counted.set(invocation.file, tally); - } - if (invocations.length === 0) { - failures.push("no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"); - } - const notes: string[] = []; - for (const [file, tally] of counted) { - if (tally.unflagged > 0) continue; - notes.push(`ok - ${file}: ${tally.total} publish invocation(s), each carrying ${ATTESTATION_FLAG}`); - } - return { failures, notes }; -} -/** - * Decide whether a tracked path can run a command. - * - * The previous enumeration named two paths -- `.github/workflows` and - * `package.json` -- which meant a publish added to any tracked script was never - * audited, and because the workflow's own attested publish satisfied the - * non-vacuity check the gate still reported that every invocation was attested. - * Auditing every shape that can execute closes that, and a shebang is honoured - * so an extensionless tracked script is not a blind spot either. - * - * Build output is excluded. `dist/` is generated from sources this scan already - * reads, it is regenerated and compared byte-for-byte on the release path, and - * including it would audit a bundled copy of a command rather than the command. - * - * @param path - Repository-relative path. - * @param firstLine - The file's first line, for shebang detection. - * @returns True when the file should be scanned. + * Deliberately carries no shebang. The auditor treats any file whose first two + * bytes are a shebang as executable shell, so adding one pulls this file into + * its own scan - and its prose, which necessarily names the command it is + * guarding, then reads as an unattested invocation. The vendored predecessor + * had no shebang for the same reason. */ -export function isExecutableSource(path: string, firstLine: string): boolean { - if (GENERATED_PREFIXES.some((prefix) => path.startsWith(prefix))) return false; - if (firstLine.startsWith("#!")) return true; - return EXECUTABLE_PATHS.some((pattern) => pattern.test(path)); -} -/** - * Read the first two bytes of a file, or nothing when it cannot be read. - * - * Only a shebang is being looked for, so the whole file is never loaded -- - * `git ls-files` can name a large tracked asset, and this runs once per - * candidate. A tracked path that cannot be opened at all (a dangling symlink, - * a file removed from the working tree but still in the index) is not a - * publish path and must not take the gate down; it reads as empty. - * - * The handle is closed by an inner `finally` rather than one wrapping the - * catch, so there is no unreachable fall-through for the coverage gate to - * report as an untested branch. - * - * @param file - Absolute path to read. - * @returns The first two bytes as text, or an empty string. - */ -function firstBytes(file: string): string { - try { - const handle = openSync(file, "r"); - try { - const buffer = Buffer.alloc(2); - readSync(handle, buffer, 0, 2, 0); - return buffer.toString("utf8"); - } finally { - closeSync(handle); - } - } catch { - return ""; - } -} +import { resolve } from "node:path"; -/** - * List the tracked files that can run a publish. - * - * Git is asked rather than the filesystem walked, so an untracked scratch copy - * of a workflow cannot satisfy or fail the gate. `-z` is used because a tracked - * path may legally contain a newline, and splitting such a listing on newlines - * invents two paths that do not exist and drops the one that does. - * - * @param root - Repository root. - * @returns Repository-relative paths of every tracked file that can execute. - */ -export function trackedPublishSources(root: string): string[] { - const listed = execFileSync("git", ["ls-files", "-z"], { - cwd: root, - encoding: "utf8", - maxBuffer: 64 * 1024 * 1024, - }); - return listed - .split("\0") - .filter((path) => path.length > 0) - .filter((path) => isExecutableSource(path, firstBytes(resolve(root, path)))); -} +import { auditPublishAttestation, report, verify } from "pm-ops/attestation"; -/** - * Read the tracked sources and audit them. - * - * @param root - Repository root to verify. - * @returns Failures and notes for the whole repository. - */ -export function verify(root: string): VerifierResult { - const sources: SourceFile[] = trackedPublishSources(root).map((file) => ({ - file, - text: readFileSync(resolve(root, file), "utf8"), - })); - return auditPublishAttestation(sources); -} +export { auditPublishAttestation, verify }; -/** - * Print a result and set a failing exit code when it failed. - * - * @param result - The audit outcome. - * @param write - Sink for the report lines. - * @param exit - Called with the process exit code when there were failures. - */ -export function report( - result: VerifierResult, - write: (line: string) => void, - exit: (code: number) => void, -): void { - for (const note of result.notes) write(note); - for (const failure of result.failures) write(`FAIL - ${failure}`); - if (result.failures.length > 0) { - write(`verify-release-publish-attestation: ${result.failures.length} failure(s).`); - exit(1); - return; - } - write("verify-release-publish-attestation: every publish invocation is attested."); -} +import { isMainInvocation } from "./main-invocation.ts"; /** * Verify and report, but only when this module is the process entry point. @@ -423,4 +50,4 @@ export function runIfMain(argv: string[], moduleUrl: string, root: string): bool return true; } -runIfMain(process.argv, import.meta.url, resolve(import.meta.dirname, "..")); +runIfMain(process.argv, import.meta.url, resolve(import.meta.dirname, "..")); \ No newline at end of file diff --git a/test/shell-command-scan.test.ts b/test/shell-command-scan.test.ts deleted file mode 100644 index c9fde1b..0000000 --- a/test/shell-command-scan.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Tests for the shared shell-text scanner and the main-invocation guard. - * - * These live beside the modules rather than inside a gate's suite because both - * release gates depend on them while not every package carries both gates. - * When these assertions belonged to the changelog-date suite, propagating the - * scanner to a package without that gate silently dropped a branch from - * coverage -- which is the failure this file exists to prevent. - */ - -import assert from "node:assert/strict"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; -import { resolve } from "node:path"; - -import { bashArrays, expandArrays, joinContinuations } from "../scripts/shell-command-scan.ts"; -import { isMainInvocation } from "../scripts/main-invocation.ts"; - -test("an unknown array reference is left in place rather than erased", () => { - // Erasing it would turn "this scan does not understand the command" into - // "this command carries no flags", which reads as a pass. - assert.equal(expandArrays('cmd "${missing[@]}"', new Map()), 'cmd "${missing[@]}"'); - assert.equal(expandArrays('cmd "${known[@]}"', new Map([["known", "--a --b"]])), "cmd --a --b"); -}); - -test("bashArrays collapses whitespace so a multi-line declaration is one flag string", () => { - assert.equal(bashArrays("common=(\n --a\n --b\n)").get("common"), "--a --b"); -}); - -test("the main-invocation guard answers both ways", () => { - // Name the module under test, not a gate: not every package carries the same - // gates, and a path that resolves nowhere makes realpathSync throw rather - // than answer. - const self = fileURLToPath(import.meta.resolve("../scripts/main-invocation.ts")); - const url = import.meta.resolve("../scripts/main-invocation.ts"); - assert.equal(isMainInvocation(["node", self], url), true); - assert.equal(isMainInvocation(["node", fileURLToPath(import.meta.url)], url), false); - assert.equal(isMainInvocation(["node"], url), false); -}); -test("a backslash continuation makes one logical command out of several lines", () => { - assert.equal( - joinContinuations("npm publish \\\n --provenance \\\n --access public\n"), - // The joiner replaces the backslash-newline with a single space and leaves - // the continuation line's own indentation, which the tokeniser then eats. - "npm publish --provenance --access public\n", - ); - // A backslash that does not end a line is an ordinary character. - assert.equal(joinContinuations("printf 'a\\tb'\n"), "printf 'a\\tb'\n"); -}); - -test("an array reference is replaced by the declaration's contents, quoted or bare", () => { - const arrays = bashArrays('common=( --access public --provenance )\n'); - assert.equal(expandArrays('npm publish "${common[@]}"', arrays), "npm publish --access public --provenance"); - assert.equal(expandArrays("npm publish ${common[@]}", arrays), "npm publish --access public --provenance"); -}); diff --git a/test/verify-release-publish-attestation.test.ts b/test/verify-release-publish-attestation.test.ts index 8328064..d9842e6 100644 --- a/test/verify-release-publish-attestation.test.ts +++ b/test/verify-release-publish-attestation.test.ts @@ -1,999 +1,132 @@ /** - * Executes the publish-attestation verifier's rules against fixtures. + * Convergence tests for the publish-attestation gate. * - * The verifier's own repository satisfies its rules, so running it here would - * only prove that today's tree is fine. What these cases prove is that each - * rule still FAILS on the defect it exists to catch -- an unattested publish - * reachable from the release workflow -- and that the two shapes which make a - * naive substring scan useless are handled: a publish spelled across a line - * continuation, and a prose mention of the command inside a quoted string. + * This repository no longer implements the gate; it consumes the canonical + * auditor from `pm-ops/attestation`. So these tests deliberately do NOT + * re-test the shell model - that suite lives with the implementation, where a + * fix reaches every consumer at once. What they assert instead is that this + * repository is still a consumer: that the gate resolves to the package export + * rather than to a local copy, and that it still refuses an unattested publish + * through that resolved path. + * + * The first is the one that matters over time. Fifteen fail-open constructions + * have been found in this gate, three of them introduced by the fix for an + * earlier one, and a copy frozen at any point in that sequence still admits + * every construction closed after it. A hand-edit that re-forks the lineage + * would otherwise be invisible. */ import assert from "node:assert/strict"; import test from "node:test"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; -import { - ATTESTATION_FLAG, - attestationEnabled, - auditPublishAttestation, - isExecutableSource, - isPublishCommand, - manifestCommandLines, - publishInvocationsIn, - report, - renderCommand, - runIfMain, - trackedPublishSources, - verify, -} from "../scripts/verify-release-publish-attestation.ts"; -import { commandArguments, commandCandidates, commandName, expandScalars, shellScalars, tokenizeCommands } from "../scripts/shell-command-scan.ts"; - -/** Tokenises one command and returns it, asserting the text held exactly one. */ -function onlyCommand(text: string): ReturnType[number] { - const commands = tokenizeCommands(text); - assert.equal(commands.length, 1, `expected one command in ${JSON.stringify(text)}`); - return commands[0]!; -} - -const ATTESTED = `npm publish --access public ${ATTESTATION_FLAG} --ignore-scripts`; -const UNATTESTED = "npm publish --access public --ignore-scripts"; - -/** Builds a throwaway git repository holding the given tracked files. */ -function trackedFixture(files: Record): string { - const root = mkdtempSync(join(tmpdir(), "attestation-")); - execFileSync("git", ["init", "-q", "."], { cwd: root }); - for (const [path, text] of Object.entries(files)) { - mkdirSync(join(root, path, ".."), { recursive: true }); - writeFileSync(join(root, path), text); - } - execFileSync("git", ["add", "-A"], { cwd: root }); - return root; -} - -test("an unattested publish fails, naming the command that would run", () => { - const result = auditPublishAttestation([{ file: "release.yml", text: ` ${UNATTESTED}` }]); - assert.equal(result.failures.length, 1); - assert.match(result.failures[0]!, /does not enable --provenance/); - assert.match(result.failures[0]!, /npm publish --access public --ignore-scripts/); -}); - -test("an attested publish passes and is reported by file", () => { - const result = auditPublishAttestation([{ file: "release.yml", text: ` ${ATTESTED}` }]); - assert.deepEqual(result.failures, []); - assert.deepEqual(result.notes, [`ok - release.yml: 1 publish invocation(s), each carrying ${ATTESTATION_FLAG}`]); -}); - -test("a file holding both an attested and an unattested publish fails, so one cannot cover for the other", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${UNATTESTED}` }, - ]); - assert.equal(result.failures.length, 1); - assert.deepEqual(result.notes, [], "a file with an unattested publish must not also be reported as ok"); -}); - -test("two publishes chained on one line are judged separately", () => { - // Judging the line as a whole would let the flag on the first call satisfy - // the second, which is exactly the shape a line-oriented scan misses. - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED} && ${UNATTESTED}` }, - ]); - assert.equal(result.failures.length, 1); -}); - -test("a publish spelled across a line continuation is still seen with its flag", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: " npm publish --access public \\\n --provenance --ignore-scripts" }, - ]); - assert.deepEqual(result.failures, []); -}); - -test("a shared bash array holding the flag is expanded rather than read as an absent flag", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` flags=( --access public ${ATTESTATION_FLAG} )\n npm publish "\${flags[@]}"` }, - ]); - assert.deepEqual(result.failures, []); -}); - -test("a prose mention of the command inside quotes is not treated as an invocation", () => { - // This repository's own workflow echoes advice naming the command. Reading - // that echo as a publish makes the gate report a defect that is not there, - // and a gate that cries wolf gets weakened until it reports nothing. - const result = auditPublishAttestation([ - { file: "release.yml", text: ` echo "The trusted publisher must have 'npm publish' selected."` }, - ]); - assert.deepEqual(result.failures, ["no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"]); -}); +import { auditPublishAttestation, report, verify } from "pm-ops/attestation"; -test("a commented-out publish is not treated as an invocation", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` # ${UNATTESTED}\n ${ATTESTED}` }, - ]); - assert.deepEqual(result.failures, []); -}); +import { runIfMain } from "../scripts/verify-release-publish-attestation.ts"; -test("a trailing unquoted comment cannot supply the flag the command lacks", () => { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${UNATTESTED} # ${ATTESTATION_FLAG}` }, - ]); - assert.equal(result.failures.length, 1); -}); +const root = resolve(import.meta.dirname, ".."); -test("a disabled attestation is not an attestation, in every spelling npm accepts", () => { - // Greptile P2: a containment check accepts `--provenance=false`, which is - // precisely the regression this gate exists to catch, and reports the file - // as attested while doing it. - for (const disabled of ["--provenance=false", "--no-provenance", "--provenance --no-provenance", "--provenance=0"]) { - assert.equal(attestationEnabled(onlyCommand(`npm publish --access public ${disabled}`)), false, disabled); - assert.equal( - auditPublishAttestation([{ file: "release.yml", text: ` npm publish --access public ${disabled}` }]).failures.length, - 1, - disabled, - ); - } - for (const enabled of ["--provenance", "--provenance=true", "--no-provenance --provenance"]) { - assert.equal(attestationEnabled(onlyCommand(`npm publish --access public ${enabled}`)), true, enabled); - } -}); +test("the gate is the resolved package export, not a local copy", async () => { + // Identity, not similarity. A vendored copy that happens to behave the same + // today is exactly what this fleet spent a session removing, because it stops + // behaving the same the moment the canonical implementation is fixed again. + assert.equal( + existsSync(resolve(root, "scripts/shell-command-scan.ts")), + false, + "a local shell-command-scan.ts means the lineage has been re-forked", + ); -test("a flag that merely starts with the attestation spelling does not enable it", () => { - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance-file x")), false); -}); + const launcher = await readFile(resolve(root, "scripts/verify-release-publish-attestation.ts"), "utf-8"); + assert.match(launcher, /from "pm-ops\/attestation"/u, "the gate must import the canonical auditor"); + assert.doesNotMatch( + launcher, + /from "\.\/shell-command-scan/u, + "the gate must not resolve any part of its shell model locally", + ); -test("a publish hidden in an npm script is found, because a manifest is JSON and its scripts are quoted", () => { - // CodeRabbit: quoted spans are erased before a command is judged, which is - // what stops the workflow's advisory echo reading as an invocation. Applied - // to a manifest that erases the script bodies themselves, so a publish moved - // into an npm script would be invisible while being entirely real. - const manifest = JSON.stringify({ scripts: { release: UNATTESTED, build: "tsc" } }); - const result = auditPublishAttestation([{ file: "package.json", text: manifest }]); - assert.equal(result.failures.length, 1, "an unattested publish in a script must fail"); - assert.match(result.failures[0]!, /does not enable --provenance/); - const attested = JSON.stringify({ scripts: { release: ATTESTED } }); - assert.deepEqual(auditPublishAttestation([{ file: "package.json", text: attested }]).failures, []); + // The functions the launcher runs are the package's own, by reference. + assert.equal(typeof verify, "function"); + assert.equal(typeof report, "function"); }); -test("manifestCommandLines survives a manifest that is malformed, empty, or has no scripts", () => { - // A malformed sibling manifest must not take the gate down; its own tooling - // reports that far better than a publish audit can. - assert.equal(manifestCommandLines("{ not json"), ""); - assert.equal(manifestCommandLines("null"), ""); - assert.equal(manifestCommandLines("[]"), ""); - assert.equal(manifestCommandLines("{}"), ""); - assert.equal(manifestCommandLines(JSON.stringify({ scripts: null })), ""); - assert.equal(manifestCommandLines(JSON.stringify({ scripts: "not-an-object" })), ""); - assert.equal(manifestCommandLines(JSON.stringify({ scripts: { a: "x", b: 3, c: "y" } })), "x\ny"); -}); +test("the resolved gate still refuses an unattested publish", () => { + // A behavioural check through the real resolved module, so "it imports the + // package" cannot pass while the package fails to load or changes shape. + const workflow = (publish: string): string => + ["jobs:", " release:", " steps:", " - run: |", ` ${publish}`].join("\n"); -test("a publish with configuration flags before the subcommand is still a publish", () => { - // Greptile: npm accepts its flags anywhere on the line, so requiring `publish` - // to follow `npm` immediately discards a real unattested publish silently -- - // and an attested sibling elsewhere in the file then carries the audit to a - // pass. - const spread = "npm --access public publish --ignore-scripts"; - assert.equal(isPublishCommand(onlyCommand(spread)), true); - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${spread}` }, + const unattested = auditPublishAttestation([ + { file: ".github/workflows/release.yml", text: workflow("npm publish --access public") }, ]); - assert.equal(result.failures.length, 1, "the unattested sibling must be counted and failed"); -}); - -test("npm run publish is a script runner, not a publish", () => { - // The script's own body is scanned from the manifest, so requiring the flag - // on the runner would report a defect that is not there. - assert.equal(isPublishCommand(onlyCommand("npm run publish")), false); - assert.equal(isPublishCommand(onlyCommand("npm run-script publish")), false); - assert.equal(isPublishCommand(onlyCommand("npm publish")), true); - assert.equal(isPublishCommand(onlyCommand("npm ci")), false); - assert.equal(isPublishCommand(onlyCommand("npm exec publish")), false, "exec runs a binary, it does not publish"); - assert.equal(isPublishCommand(onlyCommand("npm --access public publish")), true, "a flag value is not the subcommand"); - assert.equal(isPublishCommand(onlyCommand("npm --ignore-scripts publish")), true); -}); - -test("finding no publish at all fails, because an empty scan and a clean tree look identical", () => { - const result = auditPublishAttestation([{ file: "release.yml", text: " npm ci\n" }]); - assert.deepEqual(result.failures, ["no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"]); -}); - -test("only a command in command position is a publish, whatever else names npm", () => { - // CodeRabbit: searching a whole line for the word `npm` classified an - // announcement as an invocation and then failed it for lacking a flag no - // announcement could carry. What decides the question is command POSITION. - for (const mention of ["echo notnpm publish", "echo npm publish", "printf npm publish", "notnpm publish", "xnpm publish --access public"]) { - assert.deepEqual( - publishInvocationsIn({ file: "release.yml", text: ` ${mention}\n` }), - [], - mention, - ); - } - // The same words in command position, with a wrapper and a full path, are. - for (const real of ["npm publish --provenance", "/usr/local/bin/npm publish --provenance", "env CI=1 npm publish --provenance", "NPM_CONFIG_LOGLEVEL=silly npm publish --provenance"]) { - assert.equal(publishInvocationsIn({ file: "release.yml", text: ` ${real}\n` }).length, 1, real); - } -}); - -test("quoting a flag does not hide it, because the shell strips quotes before npm sees them", () => { - // CodeRabbit/Greptile: the scan blanked quoted spans, so an attested publish - // written with a quoted flag read as unattested -- and, far worse, a publish - // written inside a quoted string vanished from the audit entirely. - for (const quoted of [ - `npm publish --access public "${ATTESTATION_FLAG}"`, - `npm publish --access public '${ATTESTATION_FLAG}'`, - `npm publish --access public --provenance"" `, - `npm publish "--access" public ${ATTESTATION_FLAG}`, - ]) { - assert.deepEqual(auditPublishAttestation([{ file: "release.yml", text: ` ${quoted}` }]).failures, [], quoted); - } -}); + assert.equal(unattested.failures.length, 1); -test("an unattested publish smuggled through an interpreter or a substitution is still found", () => { - // Greptile P1 and CodeRabbit: `eval`, `bash -c` and `$(...)` payloads are - // shell text. The previous scan blanked them as quoted spans, so each of - // these published without an attestation while the workflow's own attested - // publish carried the audit to green. - for (const smuggled of [ - `eval "${UNATTESTED}"`, - `eval '${UNATTESTED}'`, - `bash -c "${UNATTESTED}"`, - `sh -c '${UNATTESTED}'`, - `output=$(${UNATTESTED})`, - "output=`npm publish --access public`", - `echo hi && eval "${UNATTESTED}"`, - ]) { - const failures = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${smuggled}` }, - ]).failures; - assert.equal(failures.length, 1, `${smuggled} -> ${JSON.stringify(failures)}`); - } -}); - -test("every shell separator ends a command, so a flagged publish cannot cover an unflagged neighbour", () => { - // The previous split knew `&&`, `||`, `;` and a space-surrounded `|` only, so - // a backgrounding `&` and a compact pipe fused two commands into one line - // that the flagged half then made pass. - for (const separator of ["&&", "||", ";", " | ", "|", "&", "\n"]) { - const text = ` ${ATTESTED} ${separator} ${UNATTESTED}`; - assert.equal( - auditPublishAttestation([{ file: "release.yml", text }]).failures.length, - 1, - `separator ${JSON.stringify(separator)}`, - ); - } -}); - -test("a publisher other than npm is refused rather than searched for a flag it has no equivalent of", () => { - for (const publisher of ["yarn", "pnpm", "bun"]) { - const result = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${publisher} publish --access public` }, - ]); - assert.equal(result.failures.length, 1, publisher); - assert.match(result.failures[0]!, new RegExp(`\\\`${publisher} publish\\\``)); - } -}); - -test("npm accepts a boolean value as a separate word, and so must this", () => { - // CodeRabbit: npm's option parser takes `--provenance false`. Reading only - // `--provenance` there reports an attestation the publish does not carry. - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance false")), false); - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance true")), true); - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance --access public")), true, "a following flag is not a value"); - assert.equal(attestationEnabled(onlyCommand("npm publish --provenance false --provenance")), true, "the last spelling wins"); -}); - -test("tokenizeCommands resolves quoting, comments and escapes the way a shell does", () => { - assert.deepEqual(onlyCommand(`a "b c" d`).map((token) => token.value), ["a", "b c", "d"]); - assert.deepEqual(onlyCommand("a 'b c'").map((token) => token.value), ["a", "b c"]); - assert.deepEqual(onlyCommand("a\\ b").map((token) => token.value), ["a b"], "an escaped space joins one word"); - assert.deepEqual(onlyCommand('x "a\\"b"').map((token) => token.value), ["x", 'a"b'], "an escaped quote stays in the word"); - assert.deepEqual(tokenizeCommands("# only a comment"), []); - assert.deepEqual(onlyCommand("npm ci # trailing comment").map((token) => token.value), ["npm", "ci"]); - assert.deepEqual(tokenizeCommands("a\\"), [[{ value: "a", quoted: false, startsQuoted: false }]], "a trailing backslash does not read past the end"); - assert.deepEqual(tokenizeCommands("echo 'unterminated").map((c) => c.map((t) => t.value)), [["echo", "unterminated"]]); - assert.equal(onlyCommand('cmd "unterminated')[1]!.quoted, true); - assert.deepEqual(commandArguments(onlyCommand("env A=1 npm publish")).map((token) => token.value), ["publish"]); - assert.equal(commandName([]), undefined); - assert.equal(commandName(onlyCommand("A=1 B=2")), undefined, "assignments alone run no command"); - assert.equal(commandName(onlyCommand("'npm' publish")), "npm", "a quoted program name still runs it"); - // startsQuoted, not quoted, is what separates an assignment from a literal - // that merely looks like one: the shell assigns for the first and not the - // second, and only the second begins inside quotes. - assert.equal(onlyCommand('A="b c" npm')[0]!.startsQuoted, false, "a quoted VALUE still starts unquoted"); - assert.equal(onlyCommand('"A=b" npm')[0]!.startsQuoted, true, "a wholly quoted word starts quoted"); - assert.equal(onlyCommand("'A=b' npm")[0]!.startsQuoted, true); - assert.equal(onlyCommand("\\A=b npm")[0]!.startsQuoted, false, "an escape is not a quote"); - assert.equal(commandName(onlyCommand('NPM_CONFIG_REGISTRY="https://r.example" npm publish')), "npm"); - assert.equal(commandName(onlyCommand('"A=b" publish')), "A=b", "a quoted literal is the program, not an assignment"); -}); - -test("every reading of a wrapper-led command is offered, so an unknown option value cannot hide a program", () => { - // commandName answers once and is right to; an auditor cannot afford that, - // because `-u` takes a value and nothing here enumerates which options do. - const values = (command: ReturnType) => - commandCandidates(command).map((candidate) => commandName(candidate)); - assert.deepEqual(values(onlyCommand("sudo -u root npm publish")), ["root", "npm", "publish"]); - assert.deepEqual(values(onlyCommand("nice -n 10 npm publish")), ["10", "npm", "publish"]); - // No wrapper means exactly one reading, so ordinary commands are untouched. - assert.deepEqual(values(onlyCommand("npm publish --provenance")), ["npm"]); - assert.deepEqual(values(onlyCommand("echo npm publish")), ["echo"]); - // A command that is nothing but a wrapper offers no reading at all. - assert.deepEqual(commandCandidates(onlyCommand("sudo")), []); - assert.deepEqual(commandCandidates([]), []); - // A reading that is only assignments names no program, and is skipped rather - // than audited as one. - // A trailing reading that is only assignments names no program, so it is - // skipped rather than audited as one. - assert.deepEqual(values(onlyCommand("sudo -u root npm publish A=1")), ["root", "npm", "publish", undefined]); - assert.deepEqual( - publishInvocationsIn({ file: "release.yml", text: " sudo -u root npm publish --provenance A=1\n" }).length, - 1, - ); -}); - -test("two identical publish lines are two findings, not one", () => { - // Collapsing them would report one invocation as if the other did not exist, - // and an operator reading "1 unattested publish" would fix half the file. - const result = auditPublishAttestation([ - { file: "release.yml", text: " npm publish\n npm publish\n" }, + const attested = auditPublishAttestation([ + { file: ".github/workflows/release.yml", text: workflow("npm publish --access public --provenance") }, ]); - assert.equal(result.failures.length, 2); -}); - -test("a substitution inside double quotes is scanned, because the shell runs it before the quotes matter", () => { - // `"$(npm publish)"` looks like one quoted word and is a real invocation. - // Treating the quoting as decisive is exactly how the previous scan lost it. - for (const smuggled of [ - `message="$(${UNATTESTED})"`, - "message=\"`npm publish --access public`\"", - `message="prefix $(${UNATTESTED}) suffix"`, - ]) { - const failures = auditPublishAttestation([ - { file: "release.yml", text: ` ${ATTESTED}\n ${smuggled}` }, - ]).failures; - assert.equal(failures.length, 1, `${smuggled} -> ${JSON.stringify(failures)}`); - } -}); - -test("unterminated and nested substitutions terminate instead of reading past the end", () => { - // A substitution's OUTPUT is not knowable here, so it contributes an empty - // word to the command that contained it while its body is scanned as - // commands in its own right. What matters is that neither shape loops or - // swallows the rest of the file. - const words = (text: string): string[][] => tokenizeCommands(text).map((command) => command.map((token) => token.value)); - assert.deepEqual(words('cmd "abc\\'), [["cmd", "abc"]], "a trailing backslash inside quotes stops at the end"); - assert.deepEqual(words('cmd "a\\\nb"'), [["cmd", "ab"]], "an escaped newline inside quotes continues the word"); - assert.deepEqual(words("cmd a\\\nb"), [["cmd", "ab"]], "and outside quotes too"); - assert.deepEqual(words("cmd $("), [["cmd", ""]], "an unterminated substitution yields an empty word and no command"); - assert.deepEqual(words("cmd `unterminated"), [["cmd", ""], ["unterminated"]], "an unterminated backtick still scans its body"); - assert.deepEqual(words("a $(echo $(npm publish)) b"), [["a", "", "b"], ["echo", ""], ["npm", "publish"]], "nesting is counted, so the inner command survives"); - assert.deepEqual(words("a $(echo \\) x) b"), [["a", "", "b"], ["echo", ")", "x"]], "an escaped paren does not close the substitution"); -}); - -test("a tracked path that cannot be opened is skipped rather than taking the gate down", () => { - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - }); - try { - symlinkSync("nowhere-at-all", join(root, "dangling")); - execFileSync("git", ["add", "dangling"], { cwd: root }); - assert.ok(!trackedPublishSources(root).includes("dangling"), "an unreadable tracked file is not a publish source"); - assert.deepEqual(verify(root).failures, [], "and it does not fail the gate either"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("evaluator recursion is bounded, so hostile nesting cannot hang the gate", () => { - let text = UNATTESTED; - // Escape backslashes before quotes. Escaping only the quote leaves a literal - // backslash in the payload able to consume the escape that follows it, so the - // nesting this test builds would not be the nesting it asserts on. - for (let depth = 0; depth < 12; depth += 1) { - text = `eval "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; - } - assert.deepEqual(tokenizeCommands(text, 9), [], "past the bound the walk stops rather than recursing"); - assert.ok(tokenizeCommands(`eval "${UNATTESTED}"`).length > 1, "within the bound the payload is still scanned"); -}); - -test("renderCommand joins the resolved tokens and caps the length of a report line", () => { - assert.equal(renderCommand(onlyCommand(`npm publish "--access" public`)), "npm publish --access public"); - assert.equal(renderCommand(onlyCommand(`npm publish ${"x".repeat(400)}`)).length, 160); -}); - -test("trackedPublishSources asks git, so an untracked workflow copy cannot satisfy the gate", () => { - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - "package.json": "{}", - }); - try { - writeFileSync(join(root, ".github/workflows/scratch.yml"), ` ${UNATTESTED}`); - assert.deepEqual(trackedPublishSources(root).sort(), [".github/workflows/release.yml", "package.json"]); - assert.deepEqual(verify(root).failures, [], "the untracked scratch copy must not be judged"); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("a publish in any tracked executable is audited, not only workflows and the manifest", () => { - // Greptile P1: the enumeration named `.github/workflows` and `package.json`, - // so a publish added to a tracked script was never read -- and because the - // workflow's own attested publish satisfied the non-vacuity check, the gate - // reported that every invocation was attested. - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - "package.json": "{}", - "scripts/ship.sh": `#!/usr/bin/env bash\n${UNATTESTED}\n`, - }); - try { - assert.ok(trackedPublishSources(root).includes("scripts/ship.sh")); - const failures = verify(root).failures; - assert.equal(failures.length, 1, JSON.stringify(failures)); - assert.match(failures[0]!, /scripts\/ship\.sh/); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("an extensionless tracked script is audited when its shebang says it executes", () => { - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - "tools/release": `#!/bin/sh\n${UNATTESTED}\n`, - "docs/notes": `${UNATTESTED}\n`, - }); - try { - const sources = trackedPublishSources(root); - assert.ok(sources.includes("tools/release"), "a shebang marks an executable source"); - assert.ok(!sources.includes("docs/notes"), "prose without a shebang is not a publish path"); - assert.equal(verify(root).failures.length, 1); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("committed build output is not audited, because it is generated from sources already read", () => { - const root = trackedFixture({ - ".github/workflows/release.yml": ` ${ATTESTED}`, - "dist/bundle.sh": `#!/bin/sh\n${UNATTESTED}\n`, - }); - try { - assert.deepEqual(trackedPublishSources(root), [".github/workflows/release.yml"]); - assert.deepEqual(verify(root).failures, []); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("isExecutableSource recognises the shapes that can run a command", () => { - for (const path of [".github/workflows/ci.yml", ".github/workflows/ci.yaml", "package.json", "web/package.json", "x.sh", "Makefile", "build/rules.mk", "Dockerfile", "Dockerfile.ci", "docker-compose.yml", "docker-compose.prod.yaml"]) { - assert.equal(isExecutableSource(path, ""), true, path); - } - for (const path of ["README.md", "src/index.ts", ".github/dependabot.yml", "package.json.bak"]) { - assert.equal(isExecutableSource(path, ""), false, path); - } - assert.equal(isExecutableSource("tools/release", "#!/bin/sh"), true, "a shebang overrides the shape"); - assert.equal(isExecutableSource("dist/bundle.sh", "#!/bin/sh"), false, "build output is excluded first"); - assert.equal(isExecutableSource("coverage/x.sh", ""), false); -}); - -test("verify reads the tracked files and fails on an unattested one", () => { - const root = trackedFixture({ ".github/workflows/release.yml": ` ${UNATTESTED}`, "package.json": "{}" }); - try { - assert.equal(verify(root).failures.length, 1); - } finally { - rmSync(root, { recursive: true, force: true }); - } + assert.deepEqual(attested.failures, []); + assert.deepEqual(attested.recognition, { kind: "recognized", count: 1 }); }); -test("report prints notes then failures and asks for a failing exit code", () => { +test("this repository's own workflows pass the gate", () => { + // The gate pointed at this checkout, which is what CI runs. Reported through + // captured streams rather than the process ones so a failure is readable. const lines: string[] = []; - const codes: number[] = []; - report({ failures: ["bad"], notes: ["fine"] }, (line) => lines.push(line), (code) => codes.push(code)); - assert.deepEqual(lines, ["fine", "FAIL - bad", "verify-release-publish-attestation: 1 failure(s)."]); - assert.deepEqual(codes, [1]); -}); - -test("report on a clean result says so and asks for no exit code", () => { - const lines: string[] = []; - const codes: number[] = []; - report({ failures: [], notes: [] }, (line) => lines.push(line), (code) => codes.push(code)); - assert.deepEqual(lines, ["verify-release-publish-attestation: every publish invocation is attested."]); - assert.deepEqual(codes, []); -}); - -test("runIfMain runs only as the entry point, and reports when it does", () => { - const root = trackedFixture({ ".github/workflows/release.yml": ` ${ATTESTED}`, "package.json": "{}" }); - const previous = process.exitCode; + let exitCode = 0; + report(verify(root), (line) => lines.push(line), (code) => { exitCode = code; }); + assert.equal(exitCode, 0, `the gate must pass on this repository:\n${lines.join("\n")}`); + assert.ok(lines.some((line) => line.includes("every publish invocation is attested"))); +}); + +test("the launcher runs only as the process entry point", () => { + // A bare `if` at module scope leaves its own body unreachable from any + // in-process test, which is how an entry point quietly stops running. + // A real path that is not this module: isMainInvocation resolves the argv + // entry, so a nonexistent one throws rather than answering the question. + assert.equal(runIfMain(["node", resolve(root, "package.json")], import.meta.url, root), false); +}); + +test("the launcher runs the gate and sets a failing exit code on an unattested publish when it is the entry point", () => { + // The positive branch: argv[1] and moduleUrl both resolve to the launcher's + // own path, so isMainInvocation answers true and runIfMain executes the gate + // for real — writing to process.stdout and setting process.exitCode. Against + // a fixture with an unattested publish, the gate must set exit code 1 and + // return true. A regression that removes the `process.exitCode = code` + // assignment from the launcher, or that makes isMainInvocation return false + // for a real invocation, leaves the gate silently exiting 0 on an unattested + // publish — exactly the failure this test's own comment warns about. + const launcherPath = resolve(root, "scripts/verify-release-publish-attestation.ts"); + const launcherUrl = pathToFileURL(launcherPath).href; + + // A fixture repository with one unattested publish, tracked by git so + // verify(root) — which calls `git ls-files` — discovers it. + const fixture = mkdtempSync(resolve(tmpdir(), "pm-github-attestation-fixture-")); try { - // isMainInvocation canonicalises both sides, so a non-entry argument must - // name a file that exists; a missing path is a different failure entirely. - assert.equal(runIfMain(["node", "scripts/main-invocation.ts"], pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, root), false); - assert.equal( - runIfMain( - ["node", "scripts/verify-release-publish-attestation.ts"], - pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, - root, - ), - true, + mkdirSync(resolve(fixture, ".github/workflows"), { recursive: true }); + writeFileSync( + resolve(fixture, ".github/workflows/release.yml"), + ["jobs:", " release:", " steps:", " - run: |", " npm publish --access public"].join("\n") + "\n", + ); + execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q"], { cwd: fixture }); + execFileSync("git", ["add", ".github/workflows/release.yml"], { cwd: fixture }); + execFileSync( + "git", + ["-c", "user.email=test@example.com", "-c", "user.name=test", "commit", "-q", "-m", "fixture"], + { cwd: fixture }, ); - assert.equal(process.exitCode, previous, "an attested tree must not set a failing exit code"); - const failing = trackedFixture({ ".github/workflows/release.yml": ` ${UNATTESTED}`, "package.json": "{}" }); + + const savedExitCode = process.exitCode; try { - runIfMain( - ["node", "scripts/verify-release-publish-attestation.ts"], - pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, - failing, - ); - assert.equal(process.exitCode, 1, "an unattested tree must set a failing exit code"); + const ran = runIfMain(["node", launcherPath], launcherUrl, fixture); + assert.equal(ran, true, "the launcher must run the gate when it is the entry point"); + assert.equal(process.exitCode, 1, "an unattested publish must set a failing exit code"); } finally { - rmSync(failing, { recursive: true, force: true }); + process.exitCode = savedExitCode; } } finally { - process.exitCode = previous; - rmSync(root, { recursive: true, force: true }); - } -}); - -test("a package runner is a wrapper, so the publish behind its own options is still audited", () => { - // Greptile raised the wrapper class generally: a publish reached through an - // interpreter or a runner escapes a scan that reads only the first word. The - // runners differ from `env` and `sudo` in that they carry their own options - // before the program, so skipping the wrapper word alone is not enough. - for (const wrapped of [ - "npx npm publish --provenance", - "npx --yes npm publish --provenance", - "bunx --bun npm publish --provenance", - "pnpx -y npm publish --provenance", - ]) { - assert.equal( - publishInvocationsIn({ file: "release.yml", text: ` ${wrapped}\n` }).length, - 1, - wrapped, - ); - } - // The same shape without the flag must fail, or the pass above proves nothing. - assert.equal( - auditPublishAttestation([{ file: "release.yml", text: " npx --yes npm publish\n" }]) - .failures.length, - 1, - ); -}); - -test("a runner spelled as two words is consumed only when its second word completes it", () => { - for (const wrapped of ["pnpm dlx npm publish --provenance", "yarn exec npm publish --provenance", "bun x npm publish --provenance"]) { - assert.equal( - publishInvocationsIn({ file: "release.yml", text: ` ${wrapped}\n` }).length, - 1, - wrapped, - ); - } - // Consuming the head word unconditionally would re-point an unrelated command - // at its first argument, so a non-matching second word leaves it alone. - assert.equal(commandName(onlyCommand("pnpm install npm publish")), "pnpm"); - assert.equal(commandName(onlyCommand("pnpm")), "pnpm"); - assert.equal(commandName(onlyCommand("bun run build")), "build"); - // And the unflagged two-word form must still fail. - assert.equal( - auditPublishAttestation([{ file: "release.yml", text: " pnpm dlx npm publish\n" }]) - .failures.length, - 1, - ); -}); - -test("an option in first position names the command it is written on, not one of its arguments", () => { - // Skipping option words is bounded to wrappers on purpose. Were it - // unconditional, a command whose own first word is an option would be - // re-pointed at an argument, and `--flag npm publish` would read as a publish - // that nothing in the tree actually runs. - assert.equal(commandName(onlyCommand("--yes npm publish")), "--yes"); - assert.deepEqual( - commandArguments(onlyCommand("--yes npm publish")).map((token) => token.value), - ["npm", "publish"], - ); - // A wrapper with nothing after it names no program rather than throwing. - assert.equal(commandName(onlyCommand("npx")), undefined); - assert.deepEqual(commandArguments(onlyCommand("npx")), []); - // A quoted option after a wrapper is a literal argument, not the wrapper's flag. - assert.equal(commandName(onlyCommand(`npx "--yes"`)), "--yes"); -}); - -test("a redirection and its target are not command words", () => { - // Greptile: `> /dev/null npm publish` runs npm, but a scan reading words in - // order sees `>` as the program and audits nothing. - const cases = [ - "> /dev/null npm publish", - ">/dev/null npm publish", - "2>/dev/null npm publish", - "2> /dev/null npm publish", - "&> /dev/null npm publish", - "npm publish > /dev/null", - "npm publish 2>&1", - ]; - for (const text of cases) { - assert.equal(commandName(onlyCommand(text)), "npm", text); - } - // The publish is still audited beside an attested sibling, which is the shape - // that made this a bypass rather than a curiosity. - const withSibling = { - file: "release.yml", - text: " npm publish --provenance\n > /dev/null npm publish\n", - }; - assert.equal(auditPublishAttestation([withSibling]).failures.length, 1); -}); - -test("a shell keyword introduces a command rather than being one", () => { - for (const text of ["if npm publish", "while npm publish", "until npm publish", "! npm publish"]) { - assert.equal(commandName(onlyCommand(text)), "npm", text); - } - // `npm exec` is a runner like `pnpm dlx`, with or without the `--` separator. - assert.equal(commandName(onlyCommand("npm exec -- npm publish")), "npm"); - assert.equal( - auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n if npm publish; then echo ok; fi\n", - }]).failures.length, - 1, - ); -}); - -test("a command held in a scalar is expanded, so the assignment is where the publish is found", () => { - const scalars = shellScalars('CMD="npm publish"\nOTHER=\'npm publish --provenance\'\nBARE=npm\n'); - assert.equal(scalars.get("CMD"), "npm publish"); - assert.equal(scalars.get("OTHER"), "npm publish --provenance"); - assert.equal(scalars.get("BARE"), "npm", "an unquoted single-word value can hold a command name"); - assert.equal(expandScalars("$CMD", scalars), "npm publish"); - assert.equal(expandScalars("${CMD}", scalars), "npm publish"); - assert.equal(expandScalars("$UNKNOWN", scalars), "$UNKNOWN", "an unknown name is left in place, not erased"); - assert.equal( - auditPublishAttestation([{ - file: "release.yml", - text: ' npm publish --provenance\n CMD="npm publish"\n $CMD\n', - }]).failures.length, - 1, - ); -}); - -test("a workflow key carries the command as its value, and is not the command", () => { - // Workflow files are scanned as raw text, so a YAML key is a word like any - // other: `run: npm publish` read `run:` as the program and audited nothing. - assert.equal(commandName(onlyCommand("run: npm publish")), "npm"); - assert.equal(commandName(onlyCommand("- run: npm publish")), "npm"); - // Only a LEADING key is consumed, so an argument that ends in a colon is not. - assert.equal(commandName(onlyCommand("echo label:")), "echo"); - assert.deepEqual( - commandArguments(onlyCommand("echo label: value")).map((token) => token.value), - ["label:", "value"], - ); - assert.equal( - auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n - run: npm publish\n", - }]).failures.length, - 1, - ); -}); - -test("a quoted parenthesis inside a substitution is a literal, not its delimiter", () => { - // Counting it closed the substitution early and truncated the body, so the - // publish after it was never scanned at all. - const result = auditPublishAttestation([{ - file: "release.yml", - text: ' npm publish --provenance\n x=$(echo ")" && npm publish)\n', - }]); - assert.equal(result.failures.length, 1); -}); - -test("one package script cannot continue into the next", () => { - // A body ending in a backslash was joined to the following script, so a - // script beginning `--provenance` lent its flag to the unattested publish - // that ended the script before it. - const manifest = JSON.stringify({ scripts: { a: "npm publish \\", b: "--provenance echo done" } }); - assert.equal(manifestCommandLines(manifest), "npm publish \n--provenance echo done"); - assert.equal(auditPublishAttestation([{ file: "package.json", text: manifest }]).failures.length, 1); -}); - -test("npm selects a workspace with a flag, so a word after it does not excuse a publish", () => { - // `workspace` was listed as a runner subcommand, which meant a `publish` - // written after it was never audited. npm has no such subcommand. - assert.equal( - auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n npm workspace pkg publish\n", - }]).failures.length, - 1, - ); - // A real runner subcommand still short-circuits: `npm run publish` runs a - // script named publish and publishes nothing. - assert.deepEqual( - publishInvocationsIn({ file: "release.yml", text: " npm run publish\n" }), - [], - ); -}); - -test("a substitution tracks both quote kinds and an escape while finding its close", () => { - // Each arm of the quote tracking has to be exercised or a later edit can - // remove one without the suite noticing. - const single = auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n x=$(echo ')' && npm publish)\n", - }]); - assert.equal(single.failures.length, 1, "a single-quoted paren is a literal"); - const escaped = auditPublishAttestation([{ - file: "release.yml", - text: " npm publish --provenance\n x=$(echo \\) && npm publish)\n", - }]); - assert.equal(escaped.failures.length, 1, "an escaped paren is a literal"); - // A double quote inside single quotes is literal, and vice versa. - assert.deepEqual( - tokenizeCommands(`x=$(echo '"' && npm publish --provenance)`).some( - (command) => commandName(command) === "npm", - ), - true, - ); -}); - -test("a scalar carrying a substitution or a quote of its own is never inlined", () => { - // This is a regression test for a defect this gate introduced in itself. - // Inlining every quoted assignment put values like `x="$(node -p …)"` into - // unrelated commands, which injected an unbalanced parenthesis, and the scan - // then reported a publish that was not there while losing the one that was -- - // a false verdict in both directions. Every package's release gate failed. - const scalars = shellScalars([ - 'CMD="npm publish"', - 'SUBST="$(node -p 1)"', - 'TICK="`date`"', - 'QUOTED="he said \'hi\'"', - 'PAREN="a (b)"', - ].join("\n")); - assert.equal(scalars.get("CMD"), "npm publish", "a plain literal is still resolved"); - for (const name of ["SUBST", "TICK", "QUOTED", "PAREN"]) { - assert.equal(scalars.get(name), undefined, `${name} must not be inlined`); - } - // The shape that actually broke: an attested publish elsewhere in the file - // must still be found, and no phantom invented. - const text = [ - ' pkg_name="$(node -p "require(\'./package.json\').name")"', - " # `npm publish` - mentioned in a comment", - " npm publish --access public --provenance --ignore-scripts", - ].join("\n"); - const found = publishInvocationsIn({ file: "release.yml", text }).map((i) => renderCommand(i.command)); - assert.deepEqual(found, ["npm publish --access public --provenance --ignore-scripts"]); -}); - -test("a substitution's quote state does not leak across its lines", () => { - // Workflow prose carries apostrophes inside double-quoted messages. If an - // unbalanced one persisted past the newline, every later parenthesis would - // look quoted and the substitution would run on past its real close, - // swallowing unrelated commands into it. - const text = [ - " x=$(echo \"GitHub's endpoint\"", - " npm publish)", - " npm publish --provenance", - ].join("\n"); - const found = publishInvocationsIn({ file: "release.yml", text }).map((i) => renderCommand(i.command)); - assert.ok(found.includes("npm publish"), "the publish inside the substitution is still found"); - assert.ok(found.includes("npm publish --provenance"), "and the one after it is not swallowed"); -}); - -test("a publish routed through an unquoted scalar is audited, not hidden by an attested sibling", () => { - // `NPM=npm` was skipped because only quoted assignments were indexed, so - // `$NPM publish` resolved to nothing and was never recognised as a publish. - // The workflow's own legitimate publish then satisfied the non-vacuity check - // and the whole audit reported a clean pass -- the gate was blind rather than - // wrong, which is the failure mode that gets a gate trusted while it is not - // looking. Only the variable-routed invocation may fail here. - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - ` npm publish --access public ${ATTESTATION_FLAG}`, - " NPM=npm", - " $NPM publish --access public", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "the variable-routed publish must be audited"); - assert.match(result.failures[0]!, /does not enable --provenance/); -}); - -test("scalar bindings are resolved at their source position", () => { - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - "NPM=npm", - "$NPM publish", - "NPM=echo", - "npm publish --provenance", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "a later reassignment cannot rewrite the earlier publish"); - - for (const operator of ["&&", "||"]) { - const controlled = auditPublishAttestation([{ - file: "release.yml", - text: `NPM=npm ${operator} $NPM publish\nnpm publish --provenance\n`, - }]); - assert.equal(controlled.failures.length, 1, `${operator} preserves the assignment before the invocation`); + rmSync(fixture, { recursive: true, force: true }); } - - const sameLine = auditPublishAttestation([{ - file: "release.yml", - text: "CMD='npm publish --provenance'; CMD='npm publish'; $CMD\nnpm publish --provenance\n", - }]); - assert.equal(sameLine.failures.length, 1, "same-line reassignment applies before the following command"); -}); - -test("assignment-shaped heredoc content cannot attest a later publish", () => { - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - "cat < { - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - "flags=( FLAG=--provenance )", - '"${flags[@]}"', - "npm publish $FLAG", - "npm publish --provenance", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "expanded array data cannot create a shell variable"); -}); - -test("an assignment-only list persists every literal binding", () => { - assert.equal(shellScalars("NPM=npm UNUSED=x\n").get("NPM"), "npm"); - assert.equal(shellScalars("NPM=npm >/dev/null\n").get("NPM"), "npm", - "redirection-only assignment commands persist their bindings"); - assert.equal(shellScalars("NPM=npm UNUSED=x$(printf y)\n").get("NPM"), "npm", - "a later dynamic binding does not discard an earlier literal binding"); - assert.equal(shellScalars("UNUSED=x$(printf y) NPM=npm\n").get("NPM"), "npm", - "an earlier dynamic binding does not hide a later literal binding"); - assert.equal(shellScalars('UNUSED="$(printf y)" NPM=npm\n').get("NPM"), "npm", - "a quoted dynamic binding does not swallow a later literal binding"); - assert.equal(shellScalars("NPM=npm UNUSED=x$(printf y) echo no\n").get("NPM"), undefined, - "a command after the bindings makes all of them temporary"); - for (const assignment of ["NPM=npm UNUSED=x", "NPM=npm UNUSED=x$(printf y)", "UNUSED=x$(printf y) NPM=npm", 'UNUSED="$(printf y)" NPM=npm', "NPM=npm >/dev/null"]) { - const result = auditPublishAttestation([{ - file: "release.yml", - text: `${assignment}\n$NPM publish\nnpm publish --provenance\n`, - }]); - assert.equal(result.failures.length, 1, "the publish routed through the first binding is audited"); - } -}); - -test("an assignment the shell never makes is not indexed", () => { - // Scalars used to be read straight out of the raw text, which indexed three - // things the shell does not assign. The middle one is a gate bypass: a name - // defined only in a COMMENT was inlined into a later command, so an - // unattested publish borrowed `--provenance` from a comment and passed. - assert.equal(shellScalars("# FLAG=--provenance\nnpm publish $FLAG\n").get("FLAG"), undefined, - "a name in a comment is not an assignment"); - assert.equal(shellScalars('# CMD="npm publish"\n').get("CMD"), undefined, - "quoting it in a comment does not make it an assignment either"); - assert.equal(shellScalars('echo "config NPM=npm"\n').get("NPM"), undefined, - "a name inside a quoted argument is not an assignment"); - assert.equal(shellScalars("NPM=npm$SUFFIX\n").get("NPM"), undefined, - "a value continuing into an expansion is not a literal, and must not be indexed by its prefix"); - assert.equal(shellScalars('"NPM=npm" publish\n').get("NPM"), undefined, - "quoting the whole word makes it a command name, not a binding"); - - // The bypass, end to end: without the fix this audit returns no failures. - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - " # FLAG=--provenance", - " npm publish --access public $FLAG", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "a publish flagged only from a comment is unattested"); - assert.match(result.failures[0]!, /does not enable --provenance/); -}); - -test("a scalar is taken only from a line that is exactly one literal assignment", () => { - assert.equal(shellScalars("NPM=npm\n").get("NPM"), "npm"); - assert.equal(shellScalars('CMD="npm publish"\n').get("CMD"), "npm publish"); - assert.equal(shellScalars("OTHER='npm publish --provenance'\n").get("OTHER"), "npm publish --provenance"); - assert.equal(shellScalars("NPM=npm\\ publish\n").get("NPM"), "npm publish", - "an escape is honoured, so one word can still hold a command"); - assert.equal(shellScalars('NPM=npm; "$NPM" publish\n').get("NPM"), "npm", - "a semicolon ends the assignment, and the shell keeps the binding after it"); - assert.equal(shellScalars("export NPM=npm\n").get("NPM"), "npm", - "export still declares a persistent binding"); - assert.equal(shellScalars("NPM=npm # explanation\n").get("NPM"), "npm", - "a trailing comment does not stop the line being an assignment"); - assert.equal(shellScalars("NPM=npm\r\n").get("NPM"), "npm", - "a CRLF line ending does not hide the assignment"); - // Refusing these left `$NPM` unresolved, and an attested publish elsewhere in - // the file then satisfied the non-vacuity guard -- so being too strict passes - // an unattested publish exactly as being too loose does. - assert.equal(shellScalars("CMD='npm publish \\--provenance'\n").get("CMD"), "npm publish \\--provenance", - "single quotes make a backslash literal, so the value is not unescaped"); - assert.equal(shellScalars("# a; FLAG=--provenance\n").get("FLAG"), undefined, - "a semicolon inside a comment does not expose an assignment"); - - // A command-scoped assignment binds only for the command it precedes; the - // shell does not keep it afterwards, so neither may this map. Storing it - // rewrote a LATER unattested publish into an attested-looking one. - assert.equal(shellScalars("FLAG=--provenance some-command\n").get("FLAG"), undefined, - "a temporary assignment does not outlive its command"); - assert.equal(shellScalars("$(FLAG=--provenance)\n").get("FLAG"), undefined, - "a binding made inside a subshell is not visible to the outer shell"); - assert.equal(shellScalars("NPM=npm$(printf foo)\n").get("NPM"), undefined, - "a literal prefix in front of a substitution is not the value"); - - // Both leaks were false passes end to end, not merely wrong map entries. - for (const text of [ - [" FLAG=--provenance | cat", " npm publish --access public $FLAG"], - [" FLAG=--provenance |& cat", " npm publish --access public $FLAG"], - [" false && FLAG=--provenance", " npm publish --access public $FLAG"], - [" false || NPM=npm", " $NPM publish --access public"], - [" export NPM=npm FOO", " $NPM publish --access public"], - [" FLAG=--provenance &", " npm publish --access public $FLAG"], - [" # ignored; FLAG=--provenance", " npm publish --access public $FLAG"], - [" FLAG=--provenance some-command", " npm publish --access public $FLAG"], - [" $(FLAG=--provenance)", " npm publish --access public $FLAG"], - ]) { - const result = auditPublishAttestation([{ file: "release.yml", text: text.join("\n") }]); - assert.equal(result.failures.length, 1, `a publish flagged only by ${text[0]!.trim()} is unattested`); - assert.match(result.failures[0]!, /does not enable --provenance|unresolved-shell-variable/); - } -}); -test("a read-write redirection does not turn its target into the command", () => { - // `<>` is one operator, not `<` followed by `>`. Unnamed, it was read as a - // joined redirection that consumes no target, so `/dev/null` became the - // command word and the real publish after it was never audited -- while an - // attested publish elsewhere satisfied the non-vacuity guard, so the whole - // audit reported clean. - const result = auditPublishAttestation([{ - file: "release.yml", - text: [ - ` npm publish --access public ${ATTESTATION_FLAG}`, - " <> /dev/null npm publish --access public", - ].join("\n"), - }]); - assert.equal(result.failures.length, 1, "the redirected publish must still be audited"); - assert.match(result.failures[0]!, /does not enable --provenance/); -}); +}); \ No newline at end of file From c16a89449246fbf7517948e4f568c06f249412f8 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:28:34 +0200 Subject: [PATCH 2/3] Assert the launcher re-exports the package's own functions, by reference The convergence guard did not guard. It asserted that the symbols imported from pm-ops/attestation are functions, and its own comment claimed they were the launcher's functions by reference - but it never compared the two. A launcher that imports the package and then re-exports a local wrapper would have passed, which is exactly the re-fork the test exists to catch. Found by Greptile. Proven not vacuous: replacing the re-export with a local arrow wrapper that still calls through to the package makes the test fail. --- .agents/pm/chores/pm-github-u5qc.toon | 5 +++-- .agents/pm/history/pm-github-u5qc.jsonl | 1 + test/verify-release-publish-attestation.test.ts | 10 +++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.agents/pm/chores/pm-github-u5qc.toon b/.agents/pm/chores/pm-github-u5qc.toon index ef2987a..9985dcf 100644 --- a/.agents/pm/chores/pm-github-u5qc.toon +++ b/.agents/pm/chores/pm-github-u5qc.toon @@ -6,13 +6,14 @@ status: closed priority: 1 tags[2]: attestation,security created_at: "2026-09-07T10:05:34.490Z" -updated_at: "2026-09-07T10:16:57.814Z" +updated_at: "2026-09-07T10:28:17.727Z" closed_at: "2026-09-07T10:16:57.788Z" completed_at: "2026-09-07T10:16:57.788Z" claim_principal: pi-agent author: pi-agent acceptance_criteria: Delete scripts/shell-command-scan.ts; Rewrite scripts/verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation; Replace test suite to assert consumption not re-test shell model -comments[1]{created_at,author,text}: +comments[2]{created_at,author,text}: "2026-09-07T10:16:40.082Z",pi-agent,"AFTER check.sh: nonliteral-overwrite,nonliteral-overwrite-cmdsub,quoted-metachar-value,single-quoted-metachar-value (4 wrong). Also fixed scripts/verify-release-changelog-date.sh: replaced $bin variable in command position with a run_changelog function, because the canonical auditor flags unresolved variables in command position as potential publish paths (by design - fail closed). BEFORE: 11 wrong. AFTER: 4 wrong, all 4 open in canonical pm-ops@2026.9.7." + "2026-09-07T10:28:17.727Z",claude,"Greptile found that the convergence guard did not actually guard. The test asserted that the symbols imported from pm-ops/attestation are functions, and its own comment claimed they were the launcher's functions by reference - but it never compared the two. A launcher that imports the package and then re-exports a local wrapper would have passed, which is exactly the re-fork the test exists to catch.\n\nThe test now imports the launcher's own bindings and asserts reference equality against the package's. Proven not vacuous: replacing the re-export with a local arrow wrapper that still calls through to the package makes the test fail, and restoring it makes it pass." close_reason: "Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass)." body: "" diff --git a/.agents/pm/history/pm-github-u5qc.jsonl b/.agents/pm/history/pm-github-u5qc.jsonl index b80bef0..830dc96 100644 --- a/.agents/pm/history/pm-github-u5qc.jsonl +++ b/.agents/pm/history/pm-github-u5qc.jsonl @@ -3,3 +3,4 @@ {"ts":"2026-09-07T10:05:41.813Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:05:41.813Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"4cfe822bbf65e10c1064dc780ab18cd1d71720a178a685dc95cc1471587afab5","after_hash":"64e9836a075318fa7c25073d8915b2f50967d13d8000abc19b1d86666c19bd1c","item_hash_version":3,"message":"Starting convergence: delete vendored scanner, rewrite attestation gate as thin launcher over pm-ops/attestation","event_class":"substantive","record_hash_version":1,"record_hash":"59049802ce12a9804f5eaa7e647df73710d19fb2b7925d77f826a46456716bd1"} {"ts":"2026-09-07T10:16:40.082Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:16:40.082Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-09-07T10:16:40.082Z","author":"pi-agent","text":"AFTER check.sh: nonliteral-overwrite,nonliteral-overwrite-cmdsub,quoted-metachar-value,single-quoted-metachar-value (4 wrong). Also fixed scripts/verify-release-changelog-date.sh: replaced $bin variable in command position with a run_changelog function, because the canonical auditor flags unresolved variables in command position as potential publish paths (by design - fail closed). BEFORE: 11 wrong. AFTER: 4 wrong, all 4 open in canonical pm-ops@2026.9.7."}]}],"before_hash":"64e9836a075318fa7c25073d8915b2f50967d13d8000abc19b1d86666c19bd1c","after_hash":"9d690d77f9fc77cad343bc2a1369d7e47a9ef876f4d5f492e0d7767f17965ebe","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"0039443765892326b26a2c4029353a156534676a7bc9e2f130166a27c3dd84f7"} {"ts":"2026-09-07T10:16:57.814Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:16:57.814Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-09-07T10:16:57.788Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-09-07T10:16:57.788Z"},{"op":"add","path":"/metadata/close_reason","value":"Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass)."}],"before_hash":"9d690d77f9fc77cad343bc2a1369d7e47a9ef876f4d5f492e0d7767f17965ebe","after_hash":"4313a0e2d515a7bfaa440432d5e5ecb3246ff5c676e63b64e673db85134e20ca","item_hash_version":3,"message":"Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass).","event_class":"substantive","record_hash_version":1,"record_hash":"532eaf60807c289d1ad69e934ebccc45fdfc86b481271d6a68c9d090f1186d53"} +{"ts":"2026-09-07T10:28:17.727Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"ac5fbc41d1fa70d2dee4261b","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.263","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-09-07T10:28:17.727Z","author":"claude","text":"Greptile found that the convergence guard did not actually guard. The test asserted that the symbols imported from pm-ops/attestation are functions, and its own comment claimed they were the launcher's functions by reference - but it never compared the two. A launcher that imports the package and then re-exports a local wrapper would have passed, which is exactly the re-fork the test exists to catch.\n\nThe test now imports the launcher's own bindings and asserts reference equality against the package's. Proven not vacuous: replacing the re-export with a local arrow wrapper that still calls through to the package makes the test fail, and restoring it makes it pass."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:28:17.727Z"}],"before_hash":"4313a0e2d515a7bfaa440432d5e5ecb3246ff5c676e63b64e673db85134e20ca","after_hash":"5500c1a301563b8e24b01a594f9fc588c8aab42b25b1273002afbe13d7cfcda6","item_hash_version":3,"message":"review round: the convergence guard did not guard","event_class":"substantive","record_hash_version":1,"record_hash":"3bd53e5f71444b344e4a03a8d55f26df2e26a6c36aa502aca88baddf0dab9f8b"} diff --git a/test/verify-release-publish-attestation.test.ts b/test/verify-release-publish-attestation.test.ts index d9842e6..380f0d3 100644 --- a/test/verify-release-publish-attestation.test.ts +++ b/test/verify-release-publish-attestation.test.ts @@ -26,7 +26,7 @@ import { pathToFileURL } from "node:url"; import { auditPublishAttestation, report, verify } from "pm-ops/attestation"; -import { runIfMain } from "../scripts/verify-release-publish-attestation.ts"; +import { runIfMain, auditPublishAttestation as launcher_auditPublishAttestation, verify as launcher_verify } from "../scripts/verify-release-publish-attestation.ts"; const root = resolve(import.meta.dirname, ".."); @@ -48,8 +48,12 @@ test("the gate is the resolved package export, not a local copy", async () => { "the gate must not resolve any part of its shell model locally", ); - // The functions the launcher runs are the package's own, by reference. - assert.equal(typeof verify, "function"); + // Identity, not similarity: the launcher must re-export the package's own + // functions. Asserting only that the package exports functions would pass + // for a launcher that imports the package and then ignores it, which is + // precisely the re-fork this test exists to catch. + assert.equal(launcher_auditPublishAttestation, auditPublishAttestation, "the launcher must re-export the package's own auditPublishAttestation"); + assert.equal(launcher_verify, verify, "the launcher must re-export the package's own verify"); assert.equal(typeof report, "function"); }); From 28398ae25da7893c859c605a0da319678e72c344 Mon Sep 17 00:00:00 2001 From: SteveBot <1153461+unbraind@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:31:55 +0200 Subject: [PATCH 3/3] Correct a wrong claim about which shebangs make a file shell input, and reproduce it The launcher docstring said the auditor treats any file whose first two bytes are a shebang as executable shell. Reproduced against the real auditor, that is false: only a shebang naming a shell interpreter makes the body shell input, so #!/usr/bin/env node leaves this file unscanned while #!/bin/bash does not. The same sentence is in all seven repositories carrying this launcher - the error travelled with the copied text. The suite now reproduces all four states rather than asserting any of them. Found by Greptile's unreproduced-claim rule. --- .agents/pm/chores/pm-github-u5qc.toon | 5 ++- .agents/pm/history/pm-github-u5qc.jsonl | 1 + scripts/verify-release-publish-attestation.ts | 13 ++++--- ...verify-release-publish-attestation.test.ts | 38 ++++++++++++++++++- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/.agents/pm/chores/pm-github-u5qc.toon b/.agents/pm/chores/pm-github-u5qc.toon index 9985dcf..715a174 100644 --- a/.agents/pm/chores/pm-github-u5qc.toon +++ b/.agents/pm/chores/pm-github-u5qc.toon @@ -6,14 +6,15 @@ status: closed priority: 1 tags[2]: attestation,security created_at: "2026-09-07T10:05:34.490Z" -updated_at: "2026-09-07T10:28:17.727Z" +updated_at: "2026-09-07T10:31:52.885Z" closed_at: "2026-09-07T10:16:57.788Z" completed_at: "2026-09-07T10:16:57.788Z" claim_principal: pi-agent author: pi-agent acceptance_criteria: Delete scripts/shell-command-scan.ts; Rewrite scripts/verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation; Replace test suite to assert consumption not re-test shell model -comments[2]{created_at,author,text}: +comments[3]{created_at,author,text}: "2026-09-07T10:16:40.082Z",pi-agent,"AFTER check.sh: nonliteral-overwrite,nonliteral-overwrite-cmdsub,quoted-metachar-value,single-quoted-metachar-value (4 wrong). Also fixed scripts/verify-release-changelog-date.sh: replaced $bin variable in command position with a run_changelog function, because the canonical auditor flags unresolved variables in command position as potential publish paths (by design - fail closed). BEFORE: 11 wrong. AFTER: 4 wrong, all 4 open in canonical pm-ops@2026.9.7." "2026-09-07T10:28:17.727Z",claude,"Greptile found that the convergence guard did not actually guard. The test asserted that the symbols imported from pm-ops/attestation are functions, and its own comment claimed they were the launcher's functions by reference - but it never compared the two. A launcher that imports the package and then re-exports a local wrapper would have passed, which is exactly the re-fork the test exists to catch.\n\nThe test now imports the launcher's own bindings and asserts reference equality against the package's. Proven not vacuous: replacing the re-export with a local arrow wrapper that still calls through to the package makes the test fail, and restoring it makes it pass." + "2026-09-07T10:31:52.885Z",claude,"Greptile's rule about unreproduced behaviour claims caught a claim that was not merely unreproduced but WRONG. The launcher docstring said the auditor 'treats any file whose first two bytes are a shebang as executable shell'. Reproduced against the real auditor, that is false: a shebang says a file executes, not that it executes AS shell, and only a shebang naming a shell interpreter makes the body shell input.\n\nMeasured by writing this file into a throwaway git repository under four shebangs and calling verify():\n #!/bin/bash -> the file is reported by name (scanned as shell)\n #!/usr/bin/env sh -> reported by name\n #!/usr/bin/env node -> NOT reported (not shell input)\n no shebang -> NOT reported, which is why this file has none\n\nThe same wrong sentence is present in all seven repositories carrying this launcher, which is what propagating a fleet change by copying a reference file produces: the error travels with the text. Corrected here and in the five sibling convergence branches; pm-csv already has it on main and needs the same correction separately.\n\nThe claim is now reproduced in the suite rather than asserted." close_reason: "Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass)." body: "" diff --git a/.agents/pm/history/pm-github-u5qc.jsonl b/.agents/pm/history/pm-github-u5qc.jsonl index 830dc96..b15abf6 100644 --- a/.agents/pm/history/pm-github-u5qc.jsonl +++ b/.agents/pm/history/pm-github-u5qc.jsonl @@ -4,3 +4,4 @@ {"ts":"2026-09-07T10:16:40.082Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:16:40.082Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-09-07T10:16:40.082Z","author":"pi-agent","text":"AFTER check.sh: nonliteral-overwrite,nonliteral-overwrite-cmdsub,quoted-metachar-value,single-quoted-metachar-value (4 wrong). Also fixed scripts/verify-release-changelog-date.sh: replaced $bin variable in command position with a run_changelog function, because the canonical auditor flags unresolved variables in command position as potential publish paths (by design - fail closed). BEFORE: 11 wrong. AFTER: 4 wrong, all 4 open in canonical pm-ops@2026.9.7."}]}],"before_hash":"64e9836a075318fa7c25073d8915b2f50967d13d8000abc19b1d86666c19bd1c","after_hash":"9d690d77f9fc77cad343bc2a1369d7e47a9ef876f4d5f492e0d7767f17965ebe","item_hash_version":3,"event_class":"substantive","record_hash_version":1,"record_hash":"0039443765892326b26a2c4029353a156534676a7bc9e2f130166a27c3dd84f7"} {"ts":"2026-09-07T10:16:57.814Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"d306d30b6b3e7d89e0e68250","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:16:57.814Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-09-07T10:16:57.788Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-09-07T10:16:57.788Z"},{"op":"add","path":"/metadata/close_reason","value":"Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass)."}],"before_hash":"9d690d77f9fc77cad343bc2a1369d7e47a9ef876f4d5f492e0d7767f17965ebe","after_hash":"4313a0e2d515a7bfaa440432d5e5ecb3246ff5c676e63b64e673db85134e20ca","item_hash_version":3,"message":"Convergence complete. Deleted scripts/shell-command-scan.ts (837 lines), rewrote verify-release-publish-attestation.ts as thin launcher over pm-ops/attestation (426 to 57 lines), replaced test suite to assert consumption. Also fixed verify-release-changelog-date.sh to use a function instead of a variable in command position. BEFORE: 11 wrong. AFTER: 4 wrong (all 4 open in canonical pm-ops@2026.9.7). All gates pass: check, docstring, coverage, verify:release-publish-attestation, test (298 pass).","event_class":"substantive","record_hash_version":1,"record_hash":"532eaf60807c289d1ad69e934ebccc45fdfc86b481271d6a68c9d090f1186d53"} {"ts":"2026-09-07T10:28:17.727Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"ac5fbc41d1fa70d2dee4261b","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.263","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-09-07T10:28:17.727Z","author":"claude","text":"Greptile found that the convergence guard did not actually guard. The test asserted that the symbols imported from pm-ops/attestation are functions, and its own comment claimed they were the launcher's functions by reference - but it never compared the two. A launcher that imports the package and then re-exports a local wrapper would have passed, which is exactly the re-fork the test exists to catch.\n\nThe test now imports the launcher's own bindings and asserts reference equality against the package's. Proven not vacuous: replacing the re-export with a local arrow wrapper that still calls through to the package makes the test fail, and restoring it makes it pass."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:28:17.727Z"}],"before_hash":"4313a0e2d515a7bfaa440432d5e5ecb3246ff5c676e63b64e673db85134e20ca","after_hash":"5500c1a301563b8e24b01a594f9fc588c8aab42b25b1273002afbe13d7cfcda6","item_hash_version":3,"message":"review round: the convergence guard did not guard","event_class":"substantive","record_hash_version":1,"record_hash":"3bd53e5f71444b344e4a03a8d55f26df2e26a6c36aa502aca88baddf0dab9f8b"} +{"ts":"2026-09-07T10:31:52.885Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_model":"claude-opus-5","agent_model_source":"probe","agent_instance":"ac5fbc41d1fa70d2dee4261b","agent_provenance":{"model":{"value":"claude-opus-5","source":"probe"},"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null,"version":{"value":"2.1.263","source":"probe"}},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-09-07T10:31:52.885Z","author":"claude","text":"Greptile's rule about unreproduced behaviour claims caught a claim that was not merely unreproduced but WRONG. The launcher docstring said the auditor 'treats any file whose first two bytes are a shebang as executable shell'. Reproduced against the real auditor, that is false: a shebang says a file executes, not that it executes AS shell, and only a shebang naming a shell interpreter makes the body shell input.\n\nMeasured by writing this file into a throwaway git repository under four shebangs and calling verify():\n #!/bin/bash -> the file is reported by name (scanned as shell)\n #!/usr/bin/env sh -> reported by name\n #!/usr/bin/env node -> NOT reported (not shell input)\n no shebang -> NOT reported, which is why this file has none\n\nThe same wrong sentence is present in all seven repositories carrying this launcher, which is what propagating a fleet change by copying a reference file produces: the error travels with the text. Corrected here and in the five sibling convergence branches; pm-csv already has it on main and needs the same correction separately.\n\nThe claim is now reproduced in the suite rather than asserted."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-09-07T10:31:52.885Z"}],"before_hash":"5500c1a301563b8e24b01a594f9fc588c8aab42b25b1273002afbe13d7cfcda6","after_hash":"5ee745cb7cf27a05deab1f8cfed0a72f3d6a9d360e529c2afdf55bcf74b14aca","item_hash_version":3,"message":"correct a wrong shebang claim, reproduced","event_class":"substantive","record_hash_version":1,"record_hash":"331d5d45621c58dd7a2dc2075416ed74d81877473f039bafabd01ad83365b882"} diff --git a/scripts/verify-release-publish-attestation.ts b/scripts/verify-release-publish-attestation.ts index 5d68969..07b8172 100644 --- a/scripts/verify-release-publish-attestation.ts +++ b/scripts/verify-release-publish-attestation.ts @@ -17,11 +17,14 @@ * root, maps the report onto the process streams, and sets the exit code. * - * Deliberately carries no shebang. The auditor treats any file whose first two - * bytes are a shebang as executable shell, so adding one pulls this file into - * its own scan - and its prose, which necessarily names the command it is - * guarding, then reads as an unattested invocation. The vendored predecessor - * had no shebang for the same reason. + * Deliberately carries no shebang. The auditor reads a shebang naming a SHELL + * interpreter as saying the file's body is shell, so `#!/bin/bash` or + * `#!/usr/bin/env sh` pulls this file into its own scan - and its prose, which + * necessarily names the command it is guarding, then reads as an unattested + * invocation. A shebang naming a non-shell interpreter does not: a shebang says + * a file executes, it does not say it executes AS shell, so `#!/usr/bin/env node` + * leaves this file unscanned. The suite reproduces all three states rather than + * asserting them. The vendored predecessor had no shebang for the same reason. */ import { resolve } from "node:path"; diff --git a/test/verify-release-publish-attestation.test.ts b/test/verify-release-publish-attestation.test.ts index 380f0d3..07690a6 100644 --- a/test/verify-release-publish-attestation.test.ts +++ b/test/verify-release-publish-attestation.test.ts @@ -18,7 +18,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -133,4 +133,38 @@ test("the launcher runs the gate and sets a failing exit code on an unattested p } finally { rmSync(fixture, { recursive: true, force: true }); } -}); \ No newline at end of file +}); + +/** + * Reproduce the shebang rule this file's own docstring relies on. + * + * The launcher deliberately carries no shebang, and the reason is a concrete + * claim about the auditor: a shebang naming a SHELL interpreter makes the + * file's body shell, at which point this file's prose - which necessarily names + * the command it is guarding - reads as an unattested publish. The claim is + * reproduced here rather than asserted, because an earlier wording of it said + * ANY shebang had that effect, and that is not what the auditor does: a shebang + * says a file executes, not that it executes as shell. + */ +test("only a shebang naming a shell interpreter pulls this file into the scan", () => { + const body = readFileSync(resolve(root, "scripts/verify-release-publish-attestation.ts"), "utf8"); + const scannedAsShell = (shebang: string): boolean => { + const dir = mkdtempSync(resolve(tmpdir(), "shebang-")); + try { + execFileSync("git", ["init", "-q"], { cwd: dir }); + mkdirSync(resolve(dir, "scripts"), { recursive: true }); + writeFileSync(resolve(dir, "scripts/verify-release-publish-attestation.ts"), shebang + body); + execFileSync("git", ["add", "-A"], { cwd: dir }); + // The file is reported by name only when the auditor read its body as + // shell; otherwise the only failure is that the throwaway repository + // contains no publish at all. + return verify(dir).failures.some((failure) => failure.includes("scripts/verify-release-publish-attestation.ts")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + assert.equal(scannedAsShell("#!/bin/bash\n"), true, "a bash shebang makes this file shell input"); + assert.equal(scannedAsShell("#!/usr/bin/env sh\n"), true, "an env sh shebang makes this file shell input"); + assert.equal(scannedAsShell("#!/usr/bin/env node\n"), false, "a node shebang does not make this file shell input"); + assert.equal(scannedAsShell(""), false, "with no shebang the file is not shell input, which is why it has none"); +});