diff --git a/.agents/pm/history/pm-github-tko1.jsonl b/.agents/pm/history/pm-github-tko1.jsonl new file mode 100644 index 0000000..d74c05d --- /dev/null +++ b/.agents/pm/history/pm-github-tko1.jsonl @@ -0,0 +1,10 @@ +{"ts":"2026-08-28T22:09:55.294Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"f992699797bca763a9b53afd","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-tko1"},{"op":"add","path":"/metadata/title","value":"The publish-attestation gate misses a publish routed through an unquoted shell variable"},{"op":"add","path":"/metadata/description","value":""},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-28T22:09:55.294Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-28T22:09:55.294Z"},{"op":"add","path":"/metadata/author","value":"claude"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"137ded4c5cea997218e47efc0dc67df526fe452165dd504d1689238fe1ab9dea","item_hash_version":2,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-28T22:09:57.974Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"f992699797bca763a9b53afd","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T22:09:57.974Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-28T22:09:57.888Z","author":"codex","text":"author=claude\ncreated_at=now\ntext=shellScalars indexed only quoted assignments, so a bare NPM=npm was never recorded. expandScalars then left $NPM unresolved, tokenizeCommands saw a program named \"$NPM\" rather than npm, and the line was not recognised as a publish at all. Because the workflow's own legitimate attested publish still satisfied the non-vacuity guard, auditPublishAttestation returned zero failures and the gate reported a clean pass over a workflow containing an unattested publish. The gate was blind rather than wrong, which is the failure mode that keeps a gate trusted while it is not looking.\n\nMeasured behaviourally across the fleet rather than by reading the sources, by importing each repository's own verify-release-publish-attestation.ts and auditing a fixture holding one attested publish plus one variable-routed unattested publish. 12 of 17 repositories returned failures=0: pm-graph, pm-starter, pm-csv, pm-context, pm-web, pm-presets, pm-ts-starter, pm-todos, pm-linear, pm-brief, pm-github and pm-slack-standup. 5 already caught it: pm-jira, pm-beads, pm-ops, pm-slack and pm-gantt-chart.\n\nFix: shellScalars accepts a third alternative for an unquoted single-word value, matching what pm-jira already carries. The existing guard that refuses to inline a value containing a substitution, backtick, quote or parenthesis is unchanged, so a value that would change how its line parses is still never inlined.\n\nThe regression test asserts the audit-level property rather than the map contents, so it fails for the reason the gate exists: with the fix reverted it reports failures=0 where 1 is required. The neighbouring assertion that an unquoted value cannot hold a command encoded the old behaviour as intentional and was updated to match, exactly as pm-jira updated it.\n\nThis is the fifth time this class of bypass has been fixed in one vendored copy while the others stayed exposed. The root cause is that the verifier is vendored into every package instead of being consumed from pm-ops; that is tracked in the companion repository as pm-cli-website-bunt."}]}],"before_hash":"137ded4c5cea997218e47efc0dc67df526fe452165dd504d1689238fe1ab9dea","after_hash":"46ff051dfbbb099d93a90805a1dc80ea934a96bdab11383f7ed7e45e7b9d1f64","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-28T22:36:31.424Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"f992699797bca763a9b53afd","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-28T22:36:31.406Z","author":"claude","text":"Bot review on the first round (Greptile P1 x2, Sourcery x2) found that reading scalar assignments out of the raw text indexes bindings the shell never makes. All four were reproduced before fixing:\n1. A name defined only in a COMMENT was indexed. `# FLAG=--provenance` followed by `npm publish --access public $FLAG` audited as attested and returned zero failures: an unattested publish borrowed its flag from a comment and passed the gate. This is a bypass, and it pre-dates the unquoted-value change because the quoted form (`# CMD=\"npm publish\"`) was indexed the same way.\n2. A name inside a quoted argument was indexed. `echo \"config NPM=npm\"` bound NPM, so a later literal `$NPM publish` was audited as an npm publish the shell never runs.\n3. An unquoted value continuing into an expansion was truncated to its prefix. `NPM=npm$SUFFIX` bound NPM to `npm`; the literal guard then saw no `$` because the `$` had been excluded from the capture, so a value the scan cannot resolve was treated as one it could.\n4. The docstring still said only quoted values are indexed.\nFixed by reading assignments from tokenizeCommands instead of from raw text. The tokeniser has already dropped comments and resolved quoting, so a word is only treated as an assignment where the shell would treat it as one; it keeps `$SUFFIX` inside the word, so the literal guard sees the whole value rather than a prefix; and only a command-leading word can assign, so `npm publish FOO=bar` is an argument rather than a binding.\nOne consequence found by the existing suite rather than by a bot: the tokeniser consumes a substitution instead of keeping its text, so `SUBST=\"$(node -p 1)\"` arrives as an empty value that the literal guard would accept. Inlining it would ERASE `$SUBST` from the command, turning \"not understood\" into \"carries no flags\" — the exact false-pass this module already warns about. Empty values are therefore never indexed.\nBoth new tests were observed failing against the previous implementation and passing after. Verified behaviourally in all 17 packages carrying this file: the comment bypass, the quoted-argument binding, the truncation and the substitution are all refused, while `NPM=npm`, `CMD=\"npm publish\"` and a leading `FOO=bar` still resolve."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T22:36:31.424Z"}],"before_hash":"46ff051dfbbb099d93a90805a1dc80ea934a96bdab11383f7ed7e45e7b9d1f64","after_hash":"983b44659c4464f6a200c04e1ae00d9f10e13ee7390fec720d4cf1a23f8b462f","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-28T22:57:20.281Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"f992699797bca763a9b53afd","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-08-28T22:57:20.179Z","author":"claude","text":"Round 3. Greptile raised three further P1 security findings against the tokeniser-based implementation, and all three reproduced:\n1. Substitution suffix. `NPM=npm$(printf foo)` — the tokeniser consumes the substitution before the literal guard sees the value, so NPM was bound to the prefix `npm`. The scan then analysed a different command from the one the shell runs.\n2. Command-scoped assignment leaked file-wide. `FLAG=--provenance some-command` binds only for that one command, but it was stored in the file-global map, so a LATER `npm publish --access public $FLAG` was rewritten into an attested-looking publish. Audit returned zero failures: a false pass.\n3. Subshell escape. `$(FLAG=--provenance)` is a binding the outer shell never sees, but the recursive tokeniser returns the nested command and its assignment was promoted into the outer map. Also a false pass, also measured at zero failures.\nReplaced the rule rather than patching three more cases. A name is now taken only from a line that is EXACTLY one assignment carrying a fully literal value, anchored at both ends. Anchoring closes the truncation pair, because a value that does not reach the end of the line is not the value. Requiring the line to hold nothing else closes the comment, quoted-argument, command-scoped and subshell cases in one rule instead of four special cases. Escapes are honoured so `NPM=npm\\ publish` still holds a command in one word, which was a Sourcery finding, and the existing refusal of any value carrying a substitution, backtick, quote or parenthesis now applies after unescaping.\n`FOO=bar npm publish` is deliberately no longer indexed. It is command-scoped, and the publish on that same line is visible to the scan directly, so nothing is lost.\nVerified across all 17 packages carrying this file by exercising 16 properties against each package's own verifier rather than by comparing files. The two regression tests were observed failing against the previous implementation and passing after.\nOne finding was accepted as valid but deliberately left out of this change: scalars are still resolved from a file-wide map, so a later assignment can rewrite an earlier use. Raised by Sourcery, reproduced, and filed as companion item pm-cli-website-1j4o because it is a different defect needing position-aware resolution."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T22:57:20.281Z"}],"before_hash":"983b44659c4464f6a200c04e1ae00d9f10e13ee7390fec720d4cf1a23f8b462f","after_hash":"72ff127d052da809bc49cf94456efdf37dc8dda63cf544b26ee76ec30b2922d7","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-29T04:11:56.170Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"50ec9a4c6e15dbcecef5b53d","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:11:56.170Z"},{"op":"add","path":"/metadata/assignee","value":"pi-agent"},{"op":"add","path":"/metadata/claim_principal","value":"pi-agent"}],"before_hash":"72ff127d052da809bc49cf94456efdf37dc8dda63cf544b26ee76ec30b2922d7","after_hash":"ebc325f833c9eb464ed79c05a599125133d9dad407994c4b0f5942e78f8f2034","item_hash_version":2} +{"ts":"2026-08-29T04:11:56.621Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"50ec9a4c6e15dbcecef5b53d","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:11:56.621Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"ebc325f833c9eb464ed79c05a599125133d9dad407994c4b0f5942e78f8f2034","after_hash":"c57ea7420fe8c434488c1e934dd5a2c455ce643b6bf3128047775247a2d235cb","item_hash_version":2} +{"ts":"2026-08-29T04:11:57.118Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"50ec9a4c6e15dbcecef5b53d","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:11:57.118Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-29T04:11:57.118Z","author":"pi-agent","text":"PR #60 review follow-up: resolving scalar bindings by source position, excluding heredoc bodies, and supporting persistent assignment-only lists."}]}],"before_hash":"c57ea7420fe8c434488c1e934dd5a2c455ce643b6bf3128047775247a2d235cb","after_hash":"0b78b4f3cc951a73e3d4303f5d2af49745b2c3744712e439da3297bd8bf1b685","item_hash_version":2} +{"ts":"2026-08-29T04:13:43.866Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"50ec9a4c6e15dbcecef5b53d","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-29T04:13:43.866Z","author":"pi-agent","text":"Implemented and verified: source-position scalar resolution prevents later reassignment from rewriting earlier commands; heredoc bodies are excluded; assignment-only lists retain each persistent literal binding. Full tests and coverage pass."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:13:43.866Z"}],"before_hash":"0b78b4f3cc951a73e3d4303f5d2af49745b2c3744712e439da3297bd8bf1b685","after_hash":"bf36e824195d8c9416287b922337386f0d2ef6db9beec545da679769b088074a","item_hash_version":2} +{"ts":"2026-08-29T04:13:44.483Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"50ec9a4c6e15dbcecef5b53d","agent_provenance":{"model":{"value":"gpt-5.6-sol","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-08-29T04:13:44.483Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-29T04:13:44.451Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-29T04:13:44.451Z"},{"op":"add","path":"/metadata/close_reason","value":"PR #60 review findings fixed with end-to-end regressions; full suite, coverage, changelog, and strict health gates pass."}],"before_hash":"bf36e824195d8c9416287b922337386f0d2ef6db9beec545da679769b088074a","after_hash":"d376377b333789c762cffaed34ee96e65b764fa7935e672248eac4cdbdb97763","item_hash_version":2} +{"ts":"2026-08-29T04:13:45.084Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-sol","agent_model_source":"environment","agent_instance":"50ec9a4c6e15dbcecef5b53d","agent_provenance":{"model":{"value":"gpt-5.6-sol","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-29T04:13:45.084Z"}],"before_hash":"d376377b333789c762cffaed34ee96e65b764fa7935e672248eac4cdbdb97763","after_hash":"aefb17914d55a182f475b36a31467964ac99adbd578bf2451cd3b58b84ebf2b0","item_hash_version":2} diff --git a/.agents/pm/issues/pm-github-tko1.toon b/.agents/pm/issues/pm-github-tko1.toon new file mode 100644 index 0000000..ab6d464 --- /dev/null +++ b/.agents/pm/issues/pm-github-tko1.toon @@ -0,0 +1,21 @@ +id: pm-github-tko1 +title: The publish-attestation gate misses a publish routed through an unquoted shell variable +description: "" +type: Issue +status: closed +priority: 1 +tags: [] +created_at: "2026-08-28T22:09:55.294Z" +updated_at: "2026-08-29T04:13:45.084Z" +closed_at: "2026-08-29T04:13:44.451Z" +completed_at: "2026-08-29T04:13:44.451Z" +author: claude +comments[2]{created_at,author,text}: + "2026-08-29T04:11:57.118Z",pi-agent,"PR #60 review follow-up: resolving scalar bindings by source position, excluding heredoc bodies, and supporting persistent assignment-only lists." + "2026-08-29T04:13:43.866Z",pi-agent,"Implemented and verified: source-position scalar resolution prevents later reassignment from rewriting earlier commands; heredoc bodies are excluded; assignment-only lists retain each persistent literal binding. Full tests and coverage pass." +notes[3]{created_at,author,text}: + "2026-08-28T22:09:57.888Z",codex,"author=claude\ncreated_at=now\ntext=shellScalars indexed only quoted assignments, so a bare NPM=npm was never recorded. expandScalars then left $NPM unresolved, tokenizeCommands saw a program named \"$NPM\" rather than npm, and the line was not recognised as a publish at all. Because the workflow's own legitimate attested publish still satisfied the non-vacuity guard, auditPublishAttestation returned zero failures and the gate reported a clean pass over a workflow containing an unattested publish. The gate was blind rather than wrong, which is the failure mode that keeps a gate trusted while it is not looking.\n\nMeasured behaviourally across the fleet rather than by reading the sources, by importing each repository's own verify-release-publish-attestation.ts and auditing a fixture holding one attested publish plus one variable-routed unattested publish. 12 of 17 repositories returned failures=0: pm-graph, pm-starter, pm-csv, pm-context, pm-web, pm-presets, pm-ts-starter, pm-todos, pm-linear, pm-brief, pm-github and pm-slack-standup. 5 already caught it: pm-jira, pm-beads, pm-ops, pm-slack and pm-gantt-chart.\n\nFix: shellScalars accepts a third alternative for an unquoted single-word value, matching what pm-jira already carries. The existing guard that refuses to inline a value containing a substitution, backtick, quote or parenthesis is unchanged, so a value that would change how its line parses is still never inlined.\n\nThe regression test asserts the audit-level property rather than the map contents, so it fails for the reason the gate exists: with the fix reverted it reports failures=0 where 1 is required. The neighbouring assertion that an unquoted value cannot hold a command encoded the old behaviour as intentional and was updated to match, exactly as pm-jira updated it.\n\nThis is the fifth time this class of bypass has been fixed in one vendored copy while the others stayed exposed. The root cause is that the verifier is vendored into every package instead of being consumed from pm-ops; that is tracked in the companion repository as pm-cli-website-bunt." + "2026-08-28T22:36:31.406Z",claude,"Bot review on the first round (Greptile P1 x2, Sourcery x2) found that reading scalar assignments out of the raw text indexes bindings the shell never makes. All four were reproduced before fixing:\n1. A name defined only in a COMMENT was indexed. `# FLAG=--provenance` followed by `npm publish --access public $FLAG` audited as attested and returned zero failures: an unattested publish borrowed its flag from a comment and passed the gate. This is a bypass, and it pre-dates the unquoted-value change because the quoted form (`# CMD=\"npm publish\"`) was indexed the same way.\n2. A name inside a quoted argument was indexed. `echo \"config NPM=npm\"` bound NPM, so a later literal `$NPM publish` was audited as an npm publish the shell never runs.\n3. An unquoted value continuing into an expansion was truncated to its prefix. `NPM=npm$SUFFIX` bound NPM to `npm`; the literal guard then saw no `$` because the `$` had been excluded from the capture, so a value the scan cannot resolve was treated as one it could.\n4. The docstring still said only quoted values are indexed.\nFixed by reading assignments from tokenizeCommands instead of from raw text. The tokeniser has already dropped comments and resolved quoting, so a word is only treated as an assignment where the shell would treat it as one; it keeps `$SUFFIX` inside the word, so the literal guard sees the whole value rather than a prefix; and only a command-leading word can assign, so `npm publish FOO=bar` is an argument rather than a binding.\nOne consequence found by the existing suite rather than by a bot: the tokeniser consumes a substitution instead of keeping its text, so `SUBST=\"$(node -p 1)\"` arrives as an empty value that the literal guard would accept. Inlining it would ERASE `$SUBST` from the command, turning \"not understood\" into \"carries no flags\" — the exact false-pass this module already warns about. Empty values are therefore never indexed.\nBoth new tests were observed failing against the previous implementation and passing after. Verified behaviourally in all 17 packages carrying this file: the comment bypass, the quoted-argument binding, the truncation and the substitution are all refused, while `NPM=npm`, `CMD=\"npm publish\"` and a leading `FOO=bar` still resolve." + "2026-08-28T22:57:20.179Z",claude,"Round 3. Greptile raised three further P1 security findings against the tokeniser-based implementation, and all three reproduced:\n1. Substitution suffix. `NPM=npm$(printf foo)` — the tokeniser consumes the substitution before the literal guard sees the value, so NPM was bound to the prefix `npm`. The scan then analysed a different command from the one the shell runs.\n2. Command-scoped assignment leaked file-wide. `FLAG=--provenance some-command` binds only for that one command, but it was stored in the file-global map, so a LATER `npm publish --access public $FLAG` was rewritten into an attested-looking publish. Audit returned zero failures: a false pass.\n3. Subshell escape. `$(FLAG=--provenance)` is a binding the outer shell never sees, but the recursive tokeniser returns the nested command and its assignment was promoted into the outer map. Also a false pass, also measured at zero failures.\nReplaced the rule rather than patching three more cases. A name is now taken only from a line that is EXACTLY one assignment carrying a fully literal value, anchored at both ends. Anchoring closes the truncation pair, because a value that does not reach the end of the line is not the value. Requiring the line to hold nothing else closes the comment, quoted-argument, command-scoped and subshell cases in one rule instead of four special cases. Escapes are honoured so `NPM=npm\\ publish` still holds a command in one word, which was a Sourcery finding, and the existing refusal of any value carrying a substitution, backtick, quote or parenthesis now applies after unescaping.\n`FOO=bar npm publish` is deliberately no longer indexed. It is command-scoped, and the publish on that same line is visible to the scan directly, so nothing is lost.\nVerified across all 17 packages carrying this file by exercising 16 properties against each package's own verifier rather than by comparing files. The two regression tests were observed failing against the previous implementation and passing after.\nOne finding was accepted as valid but deliberately left out of this change: scalars are still resolved from a file-wide map, so a later assignment can rewrite an earlier use. Raised by Sourcery, reproduced, and filed as companion item pm-cli-website-1j4o because it is a different defect needing position-aware resolution." +close_reason: "PR #60 review findings fixed with end-to-end regressions; full suite, coverage, changelog, and strict health gates pass." +body: "" diff --git a/CHANGELOG.md b/CHANGELOG.md index 450bee9..e97d01a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- The publish-attestation gate misses a publish routed through an unquoted shell variable ([pm-github-tko1](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-tko1.toon)) - True round-trip GitHub sync: search provider, validate diagnostics, safe-by-default export, fix activation ([pm-github-9dqy](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-9dqy.toon)) - A failed provenance publish silently falls back to an unattested one ([pm-github-i5b8](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-i5b8.toon)) - Fix release publish ordering ahead of protected main push ([pm-github-v2kt](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-v2kt.toon)) diff --git a/scripts/shell-command-scan.ts b/scripts/shell-command-scan.ts index e450360..84e7e00 100644 --- a/scripts/shell-command-scan.ts +++ b/scripts/shell-command-scan.ts @@ -343,14 +343,18 @@ export function tokenizeCommands(text: string, depth = 0): ShellCommand[] { * `> /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>>`), and the duplicating forms (`>&`, `2>&1`, `&>`). + * (`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); + return /^(?:[0-9]*(?:>>?|<>|<>?)$/.test(token.value); } /** @@ -550,39 +554,246 @@ export function bashArrays(text: string): Map { 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. - * - * Only literal single- or double-quoted values are indexed. An unquoted value - * cannot hold a space and so cannot hold a command, and a value built from - * other variables is not resolvable without evaluating the script, which this - * module deliberately does not do. + * 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. - * @returns Variable name mapped to the literal text it holds. + * 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(); - for (const match of text.matchAll(/(?:^|[\s;&|])([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"\n]*)"|'([^'\n]*)')/g)) { - // The alternation guarantees exactly one of the two value groups matched, - // so there is no third case to fall back to. - const value = match[2] ?? match[3]!; - // Only a plain literal is inlined. A value carrying a substitution, a - // backtick, or a quote of its own changes how the line it lands in parses: - // 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. That is a false verdict in both - // directions, which is worse than not resolving the variable at all. - if (/[$`"'()]/.test(value)) continue; - scalars.set(match[1]!, value); + 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. * diff --git a/scripts/verify-release-publish-attestation.ts b/scripts/verify-release-publish-attestation.ts index e3dc6a0..2237fb5 100644 --- a/scripts/verify-release-publish-attestation.ts +++ b/scripts/verify-release-publish-attestation.ts @@ -27,9 +27,8 @@ import { commandCandidates, commandName, expandArrays, - expandScalars, + expandShellScalars, joinContinuations, - shellScalars, type ShellCommand, type SourceFile, tokenizeCommands, @@ -206,10 +205,9 @@ 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 scalars = shellScalars(text); - const expanded = text + const expanded = expandShellScalars(text) .split("\n") - .map((line) => expandScalars(expandArrays(line, arrays), scalars)) + .map((line) => expandArrays(line, arrays)) .join("\n"); const found: PublishInvocation[] = []; for (const command of tokenizeCommands(expanded)) { @@ -220,11 +218,15 @@ export function publishInvocationsIn(source: SourceFile): PublishInvocation[] { for (const candidate of commandCandidates(command)) { const program = commandName(candidate); if (program === undefined) continue; - if (program !== "npm" && !FOREIGN_PUBLISHERS.has(program)) 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, command: candidate }); + found.push({ file: source.file, program: unresolvedPublisher ? "unresolved-shell-variable" : program, command: candidate }); } } return found; diff --git a/test/verify-release-publish-attestation.test.ts b/test/verify-release-publish-attestation.test.ts index b6ad38d..8328064 100644 --- a/test/verify-release-publish-attestation.test.ts +++ b/test/verify-release-publish-attestation.test.ts @@ -641,7 +641,7 @@ test("a command held in a scalar is expanded, so the assignment is where the pub 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"), undefined, "an unquoted value cannot hold a command"); + 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"); @@ -775,3 +775,225 @@ test("a substitution's quote state does not leak across its lines", () => { 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`); + } + + 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/); +});