diff --git a/BUILD-LOG.md b/BUILD-LOG.md index b912071b1..a96d4b263 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5816,3 +5816,9 @@ Before: agents can read the library; supporting-path and push-credential depende 2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog. 2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved. + +2026-10-28T22:34:42-05:00 | Sage FIX-713 seam investigation AFTER/BLOCKED | Branch sage/q284-713-pi-undici at exact base 09a847edaa25b609bac7728ff83f224708a29399. Checksum-verified Pi0.85.1 registry artifact (lock SHA512 FGRN+OHb...L/oQ==) maps bin pi to dist/bundle/cli.js; that CLI imports chunk-JVUZSMYM.js, which embeds node_modules/undici/index.js and Undici8.9.0 advisory signatures (orphaned fetch body before content-encoding reject; HTTP1/2 ERR_TLS_CERT_ALTNAME_INVALID assertions). Exact upstream v0.85.1 bundler bundles all non-allowlisted packages and does not externalize Undici; root8.12.2 override would therefore be installed but not consumed. Public registry metadata latest1.4.0 and all published declared Undici values remain below8.12.2. Read-only focused source verification PASS; no product/lock/test change, no full gate/container/native/provider/live/API operation, commit, push or PR. Minimal missing boundary is an upstream exact pinned Pi artifact rebuilt with bundled Undici>=8.12.2, or separate authorization and compatibility admission for a rebuilt/alternate entry artifact. Owned scratch /mnt/storage/scratch/tmp/q284-713.l8x9AG disposed. + +2026-10-29T00:37:47-05:00 | Sage Mos2248/FIX-713 source-only rebuild recipe AFTER/READY-FOR-PARENT-REVIEW | On sage/q284-713-pi-undici at unchanged 09a847edaa25b609bac7728ff83f224708a29399, added only packages/pi-rebuild plus append-only receipts. Exact Pi v0.85.1 commit d981de1 source/tool/package pins; zero-context three-target patch sha256 6f1c3aaf5c6bdf08cfb02932d77251d8048130ba59c63a099b25cae26cb0bf0e moves bundled Undici8.9.0->8.12.2 and full-A5 brace-expansion5.0.9->5.0.12, retaining all unrelated pins and the truthful unchanged public-registry install-lock. Actual published CLI closure=40 files, contains chunk-JVUZSMYM.js, has no external Undici import, and fixed verifier refuses its8.9.0 shrinkwrap. Exact clean/preimage and patched/postimage validation PASS; 3 fixture tests PASS (fail-closed pins/patch, source graph, reachable-bundle signatures plus deterministic manifest/SPDX/provenance logic); bash/node syntax and tracked/untracked whitespace checks PASS. Recipe/build/CI instructions explicitly UNRUN; no fetched-code execution, package install, rebuild/artifact, runtime/package-lock adoption, full gate, container/native/provider/live/credential/API/commit/push/PR/release/publish/spend action. Later consumption still needs Jason choice: immutable internal/local artifact+hash adoption or fork/new identity/version. Owned scratch /mnt/storage/scratch/tmp/q284-713-recipe.U19vvs disposed. + +2026-10-29T00:51:13-05:00 | Sage Mos2248/FIX-713 parent-precheck correction AFTER/READY-FOR-PARENT-REVIEW | CORRECTION to the 00:37 entry: its custom-prefix scratch `/mnt/storage/scratch/tmp/q284-713-recipe.U19vvs` did not satisfy the required exact `mktemp -d -p /mnt/storage/scratch/tmp` command (parent confirmed it absent), and its readiness claim preceded discovery of stale Node22.19.0 and incomplete whitespace handling in the TLS signature guard. Corrected the still-uncommitted recipe to exact official patched Node22.23.4/npm10.9.9 (archive sha256 1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce; PGP unverified; binary unobserved), retained Pi0.85.1/esbuild0.28.1/all unrelated pins and unchanged patch6f1c3aaf. Parent TLS control RED2/1 reproduced, then GREEN3/0 with all three compact/spaced/assert-space vulnerable forms refused while the fixed abort and non-vulnerable equality control remain accepted; the guard is explicitly only a known-direct-signature scan, not universal JS proof. Exact source trace selected upstream `build:offline`; regular AI build has live catalog fetches, offline build only validates committed model data/compiles/copies, and `check:package-install` uses ignore-scripts registry materialization plus PI_OFFLINE/telemetry-off import/`--version` smoke. Driver now enforces owned mode0700 exact-shaped mktemp root, empty user/global npm configs, offline build mode and three build-input hosts. Fresh exact archive clean/preimage and patched/postimage validation PASS; published Pi reference sha/size reverified, actual CLI closure40/chunk-JVUZSMYM/no external Undici/embedded8.9 vulnerable signatures reconfirmed and refused; Undici8.12.2 and brace5.0.12 package sha/size plus four fixed-source hashes reverified; recipe fixtures3/0, validate-recipe, bash/node/JSON syntax and tracked/untracked whitespace PASS. No Node binary download/execution, fetched-code execution, install, rebuild/artifact, Mosaic compatibility/full gate, runtime/lock adoption, provider/live/credential/API/commit/push/PR/release/publish/spend action. Compliant owned scratch `/mnt/storage/scratch/tmp/tmp.lqYHVE1uKt` disposed; parent review remains before commit. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 70d32e024..1f1049152 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -1879,3 +1879,9 @@ are never rewritten or removed; corrections are new entries. 2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog. 2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved. + +2026-10-28T22:34:42-05:00 | Sage | FIX-713 Pi Undici consumer seam on sage/q284-713-pi-undici | Exact verified Pi0.85.1 bundle embeds vulnerable Undici8.9.0; no external override seam or compatible in-scope fix. Focused source assertions PASS; required upstream rebuilt exact artifact or separately reviewed alternate-entry/rebuild compatibility boundary reported. No product/lock/test/full-gate/container/native/provider/live/API/commit/push/PR action; owned scratch disposed. + +2026-10-29T00:37:47-05:00 | Sage | Mos2248/FIX-713 source-only Pi0.85.1 rebuild recipe on sage/q284-713-pi-undici | Prepared review-ready exact-source recipe, three-file upstream patch (Undici8.12.2 plus A5-required brace-expansion5.0.12), fail-closed pre/postimage and actual bundle-closure/signature checks, deterministic manifest/SPDX/provenance writers, and UNRUN focused CI/no-model acceptance. Local source/fixture/static evidence PASS; actual rebuild/artifact/adoption/CI/full gate and all protected operations unrun. Parent review required before commit; later consumption needs Jason artifact-source identity choice. Owned scratch /mnt/storage/scratch/tmp/q284-713-recipe.U19vvs disposed. + +2026-10-29T00:51:13-05:00 | Sage | FIX-713 parent-precheck correction on sage/q284-713-pi-undici at base09a847ed | Supersedes prior readiness defects: records custom-prefix scratch deviation, updates exact unrun toolchain to patched Node22.23.4/npm10.9.9, uses exact upstream build:offline after source-tracing model/network and check:package-install behavior, fixes known direct TLS signature whitespace discrimination with parent RED2/1→GREEN3/0 controls, and narrows driver/README cleanup to exact owned mode0700 mktemp roots. Exact source/patched/bundle/package/static checks PASS; build/install/artifact/Mosaic compatibility/full CI and protected operations remain UNRUN; no commit/push/review dispatch. Compliant scratch /mnt/storage/scratch/tmp/tmp.lqYHVE1uKt disposed; prior custom-prefix path confirmed absent. diff --git a/packages/pi-rebuild/PIN.json b/packages/pi-rebuild/PIN.json new file mode 100644 index 000000000..87ccf8eb1 --- /dev/null +++ b/packages/pi-rebuild/PIN.json @@ -0,0 +1,151 @@ +{ + "schemaVersion": 1, + "state": "UNRUN", + "ruling": "Mos2248", + "pi": { + "name": "@earendil-works/pi-coding-agent", + "version": "0.85.1", + "tag": "v0.85.1", + "commit": "d981de1229ef899957bbe968bc8dcda02a21f477", + "tree": "346294a615d2d0ad4f6e5fbccb4cee4ccd7b2d6c", + "commitTimestamp": "2026-09-05T11:54:46Z", + "sourceDateEpoch": 1788609286, + "source": { + "url": "https://codeload.github.com/earendil-works/pi/tar.gz/d981de1229ef899957bbe968bc8dcda02a21f477", + "archiveRoot": "pi-d981de1229ef899957bbe968bc8dcda02a21f477", + "bytes": 6978387, + "sha256": "d2a04b35e57f59873d67f667f22e681e93c78a46ba8e2cdedf9d4c323b1ae8dc", + "sha512": "77acb7819e75da17059cb507064739d6eaf033ad76b9a5d2b090cb607bf97ddff5a94ec0ae57ce5f1c168c95f370b6e97513e667fdb47b53ed55f486286a2ae9" + }, + "registryReference": { + "metadataUrl": "https://registry.npmjs.org/@earendil-works%2fpi-coding-agent/0.85.1", + "metadataSha256": "3f6f840ec6df9cc731e6c9d1dca4ca31ffa609797fb8b0ee78aaf34e2981c3c0", + "tarballUrl": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz", + "tarballBytes": 6986356, + "tarballSha256": "1f498729649bdce647d1160993b4d92bf3c614cc819213bee2f91dd34f2a7af4", + "tarballSha512": "14644df8e1db59a79f04f19a4e080074b8eb2075beb363f32f2825cffe5d7cbcdbf687fbbae5cc5d80b47c921e653c3eb214b7da8d9cb90f6317b6120ee2ffa1", + "integrity": "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==", + "shasum": "4cd00f653c3dabeb193b46f511044e7fbfe0f947", + "gitHead": "d981de1229ef899957bbe968bc8dcda02a21f477", + "attestationsUrl": "https://registry.npmjs.org/-/npm/v1/attestations/@earendil-works%2fpi-coding-agent@0.85.1", + "attestationsSha256": "bb337af8436f7596a127d43c067d10103ede1d4aa5ebe143a2dafdc9d24291bd" + } + }, + "undici": { + "name": "undici", + "version": "8.12.2", + "gitHead": "bb6cc0ae9b6827ed273ed5ba344148273629638f", + "metadata": { + "url": "https://registry.npmjs.org/undici/8.12.2", + "sha256": "9da5247dcaedb24bde2a1434c418f067f0e19ef272a032e99588076c14ec2487" + }, + "tarball": { + "url": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", + "bytes": 542130, + "sha256": "a765b5e63ab5c6b1b91a59072c88f7857b36f47b09c376762241d177279cdefd", + "sha512": "5edb8359576a4cbaf7340c66da5cbdfe153a914b093a01a1d51012ccc05bb30e9a58239a4331dd5be4100df7444637c9d95e6f2f9a513dae117ad3bd51f8af0e", + "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", + "shasum": "e869237430adcc5de6bca798c94edd7db9dac5ac" + }, + "attestations": { + "url": "https://registry.npmjs.org/-/npm/v1/attestations/undici@8.12.2", + "sha256": "6b1d108f4e81e752548a8b1cda5aedb4246c02749bd3b30162bf356e6f5709ca" + }, + "fixedSourceFiles": { + "lib/web/fetch/index.js": "e3cc1063bb3bb8353f918d2b1c54b8d6991a71b06c4278959f0c2ef412a433b1", + "lib/dispatcher/client-h1.js": "b4473e4ef9e6a3fb5ca0de69fd705c3797d138e7586e40beb49fa13b58c8a370", + "lib/dispatcher/client-h2.js": "9acfef9064b7639a937d5f279e93bafb831ca2e3c018b4e3864b198f97368fb9", + "package.json": "16723528eaa06df7f3b29ea52584e9127608fd9e8de066c39339aa806ad9da84" + } + }, + "braceExpansion": { + "name": "brace-expansion", + "version": "5.0.12", + "gitHead": "f3410159d768f56c9d9f4511d3e1b46425fc1099", + "metadata": { + "url": "https://registry.npmjs.org/brace-expansion/5.0.12", + "sha256": "5421e68ea58a0286041e3f11998da4fa44055026b11c527cc14ec74895b58929" + }, + "tarball": { + "url": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "bytes": 12620, + "sha256": "ef8448ec78f20b692f04fa6d01f39b5ab34c66404bea3429f5a39c6c9e0be8b4", + "sha512": "628bd0debce168b308ac38cd0cc90d4b4d6d79af77aa11211b9c72f1febe47497e770dca8bee6c0a822823de368ae2d94c75a881692d830543854d3d88d12299", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "shasum": "995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" + } + }, + "toolchain": { + "platform": "linux-x64", + "node": { + "version": "22.23.4", + "url": "https://nodejs.org/dist/v22.23.4/node-v22.23.4-linux-x64.tar.xz", + "sha256": "1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce", + "releaseUrl": "https://nodejs.org/en/blog/release/v22.23.4", + "securityReleaseUrl": "https://nodejs.org/en/blog/vulnerability/october-2026-security-releases", + "shasumsUrl": "https://nodejs.org/dist/v22.23.4/SHASUMS256.txt", + "shasumsSha256": "c90c3f1478fda8308b22cc1a1b0dc536169196444e67ee8b5c582fd351d02bce", + "pgpSignatureVerified": false + }, + "npm": { + "version": "10.9.9", + "packageJsonUrl": "https://raw.githubusercontent.com/nodejs/node/v22.23.4/deps/npm/package.json", + "packageJsonSha256": "7c04bf69824d17a1db64defd3a9a86d538781bf2d6ca2764e3f1143fa5b71166" + }, + "esbuild": { + "version": "0.28.1" + } + }, + "patch": { + "path": "patches/pi-0.85.1-undici-8.12.2.patch", + "sha256": "6f1c3aaf5c6bdf08cfb02932d77251d8048130ba59c63a099b25cae26cb0bf0e", + "allowedTargets": [ + "package-lock.json", + "packages/coding-agent/npm-shrinkwrap.json", + "packages/coding-agent/package.json" + ] + }, + "sourcePreimages": { + ".npmrc": "d2c8ca514b23b7378b7ceb25e9599954e7c9d83328dc53185e2b69ba1b2dc146", + "package.json": "7e305a4e6382517028f3b6fdf2b2350c38fa89a24e2cbec6ac84e8d99f99ba1d", + "package-lock.json": "e3569b99e673a0051be908f2bed57651b90a966a5a0d208913c29e6830a6a6c2", + "packages/ai/package.json": "b54df5a36d523febdeebfc5682dc4faed101fee10aba913d5f3679582f018da3", + "packages/ai/scripts/check-model-data.ts": "818e463211322ab41fe4c82eb8e9543fe6b40c1bfb734999d49d87f2071802aa", + "packages/ai/scripts/generate-models.ts": "188383e7107430d2c3321a8435e3295daf0877317cbab94b67a58e6afaec41d6", + "packages/ai/scripts/model-data.ts": "9265bb6f494765be06ad42e50b6f5667c88b690670e9dfd673cb55513d183056", + "packages/coding-agent/package.json": "f1738e4b42203e5f22bcb513f13fb2fb224f1e98d1f129ff042f87048665a94c", + "packages/coding-agent/npm-shrinkwrap.json": "5ff461d87e0a7ccac07e6a9eb3edcd18841d3373d375bed043606b51082f1a26", + "packages/coding-agent/install-lock/package.json": "9079bdf9f1b920e228bee48a3f5aae8c2b83361e3a518436616b91b0445ead33", + "packages/coding-agent/install-lock/package-lock.json": "48d12884e64315740002a71c9aeed8a07f435163f702f1e3582251607fd11ea3", + "scripts/build-coding-agent-bundle.mjs": "ba226d9c812ee1610cf61a0ad3efa8920f672a309fb32d122b2b3a4148071e8a", + "scripts/generate-coding-agent-shrinkwrap.mjs": "494af1eda0552f570df6e4173e9db58b16414db91de43256c9abe0d3bd69067b", + "scripts/generate-coding-agent-install-lock.mjs": "1ceab3fe345dd1441a098bc8d147e63216f4e0700779ec3ec3d3a9a1917d57fc", + "scripts/coding-agent-consumer.mjs": "fe947e1e9140ea33567b32705106ce3c725bd98fb024ace2ed8eafe795ac1580", + "scripts/package-workspaces.mjs": "4214d03e1fdcbe245b903ff938da50c3ccf93a0aa762157f6d58f80e0d1ae955", + "scripts/release-packages.mjs": "1e8f1e35011df54a790109fe42441523818d41e0f4815fe7011ffb14d8014827" + }, + "sourcePostimages": { + ".npmrc": "d2c8ca514b23b7378b7ceb25e9599954e7c9d83328dc53185e2b69ba1b2dc146", + "package.json": "7e305a4e6382517028f3b6fdf2b2350c38fa89a24e2cbec6ac84e8d99f99ba1d", + "package-lock.json": "b54fedeedc2c042caef98e7b2e21c19f99553abaebe269d648d287875ce54657", + "packages/ai/package.json": "b54df5a36d523febdeebfc5682dc4faed101fee10aba913d5f3679582f018da3", + "packages/ai/scripts/check-model-data.ts": "818e463211322ab41fe4c82eb8e9543fe6b40c1bfb734999d49d87f2071802aa", + "packages/ai/scripts/generate-models.ts": "188383e7107430d2c3321a8435e3295daf0877317cbab94b67a58e6afaec41d6", + "packages/ai/scripts/model-data.ts": "9265bb6f494765be06ad42e50b6f5667c88b690670e9dfd673cb55513d183056", + "packages/coding-agent/package.json": "d63c0319c402cb3f855f46dc724a63b771e6e40ca872b698273717f482c5760d", + "packages/coding-agent/npm-shrinkwrap.json": "ac5a5b05a700c98f97d573973c54fd5f875b7e0be63f6f3612f07cc029fb5f65", + "packages/coding-agent/install-lock/package.json": "9079bdf9f1b920e228bee48a3f5aae8c2b83361e3a518436616b91b0445ead33", + "packages/coding-agent/install-lock/package-lock.json": "48d12884e64315740002a71c9aeed8a07f435163f702f1e3582251607fd11ea3", + "scripts/build-coding-agent-bundle.mjs": "ba226d9c812ee1610cf61a0ad3efa8920f672a309fb32d122b2b3a4148071e8a", + "scripts/generate-coding-agent-shrinkwrap.mjs": "494af1eda0552f570df6e4173e9db58b16414db91de43256c9abe0d3bd69067b", + "scripts/generate-coding-agent-install-lock.mjs": "1ceab3fe345dd1441a098bc8d147e63216f4e0700779ec3ec3d3a9a1917d57fc", + "scripts/coding-agent-consumer.mjs": "fe947e1e9140ea33567b32705106ce3c725bd98fb024ace2ed8eafe795ac1580", + "scripts/package-workspaces.mjs": "4214d03e1fdcbe245b903ff938da50c3ccf93a0aa762157f6d58f80e0d1ae955", + "scripts/release-packages.mjs": "1e8f1e35011df54a790109fe42441523818d41e0f4815fe7011ffb14d8014827" + }, + "securityEvidence": { + "briefSha256": "83b9462ba40e93934fcf3cc96617f59dff3950d4321e3351eb49ab83a03c1359", + "a5ReportSha256": "71270a41a957921b5a83b09650f817fdaef31ef4e762f03a68c8eed8207fb307", + "a5MetadataSha256": "4ae5158491a25d99778fa84cefe65260c0d6aed701b6e710de2c80ba8e39877f" + } +} diff --git a/packages/pi-rebuild/README.md b/packages/pi-rebuild/README.md new file mode 100644 index 000000000..7fee137f3 --- /dev/null +++ b/packages/pi-rebuild/README.md @@ -0,0 +1,153 @@ +# Pi 0.85.1 / Undici 8.12.2 source rebuild recipe + +Status: **UNRUN**. Mos2248 authorizes preparation of this source-only recipe. It does not authorize running fetched code, installing packages, building or adopting an artifact, switching the runtime, publishing, releasing, spending, using credentials, or contacting a model/provider. Nothing in this directory is a claim that Mosaic currently consumes a fixed bundle. + +This directory is intentionally outside Mosaic's root dependency graph. It adds no root override and changes neither `package.json` nor `package-lock.json`. A root override would be cosmetic: Pi 0.85.1's `bin.pi` is `dist/bundle/cli.js`, its esbuild graph has `bundle: true`, and Undici is not allowlisted as external. The published bundle therefore consumes its embedded Undici, not Mosaic's root resolution. + +## Exact inputs + +`PIN.json` is the machine-readable authority. `src/recipe.mjs` independently enforces its security-critical values and refuses drift. + +| Input | Exact identity | SHA-256 | +|---|---|---| +| Pi source | tag `v0.85.1`, commit `d981de1229ef899957bbe968bc8dcda02a21f477`, tree `346294a615d2d0ad4f6e5fbccb4cee4ccd7b2d6c` | source archive `d2a04b35e57f59873d67f667f22e681e93c78a46ba8e2cdedf9d4c323b1ae8dc` | +| Published Pi reference (not a build input) | `@earendil-works/pi-coding-agent@0.85.1`, `gitHead` equal to the commit above | tarball `1f498729649bdce647d1160993b4d92bf3c614cc819213bee2f91dd34f2a7af4` | +| Fixed package | `undici@8.12.2`, `gitHead bb6cc0ae9b6827ed273ed5ba344148273629638f` | tarball `a765b5e63ab5c6b1b91a59072c88f7857b36f47b09c376762241d177279cdefd` | +| A5 companion pin | `brace-expansion@5.0.12`, `gitHead f3410159d768f56c9d9f4511d3e1b46425fc1099` | tarball `ef8448ec78f20b692f04fa6d01f39b5ab34c66404bea3429f5a39c6c9e0be8b4` | +| Toolchain | Node `22.23.4` Linux x64 with bundled npm `10.9.9`; esbuild remains `0.28.1` from Pi's lock | Node archive `1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce` | +| Patch | `patches/pi-0.85.1-undici-8.12.2.patch` | `6f1c3aaf5c6bdf08cfb02932d77251d8048130ba59c63a099b25cae26cb0bf0e` | + +The Pi npm attestation binds the published 0.85.1 reference tarball to the same upstream tag and commit. The exact GitHub commit is unsigned, so the recipe does not claim a signed source commit. The Undici npm attestation binds 8.12.2 to `bb6cc0ae9b6827ed273ed5ba344148273629638f`. The npm attestation endpoint returned 404 for brace-expansion 5.0.12 during preparation; its exact registry metadata (including registry signatures), tarball integrity, Git head, and hashes are pinned without claiming provenance attestation. Exact metadata and attestation response hashes where present, archive SHA-512 values, source/postimage hashes, and the original FIX-713/A5 evidence hashes are in `PIN.json`. + +Node 22.23.4 is the 2026-10-21 security release on the same major line permitted by Pi's unchanged upstream `node >=22.19.0` engine. The official release page and October 2026 security notice identify the 22.x fixes; the official `SHASUMS256.txt` digest, archive digest, and exact `deps/npm/package.json` digest establishing bundled npm 10.9.9 are pinned. Preparation did not download or execute the Node archive and did not verify the published PGP signature, so no binary-observation or PGP-verification claim is made. + +## Minimal source change + +The patch changes exactly three existing Pi files. It replaces seven Undici `8.9.0` literals and the two `brace-expansion@5.0.9` lock resolutions: + +- `packages/coding-agent/package.json`: direct dependency only; +- `package-lock.json`: the coding-agent workspace edge plus top-level Undici and brace-expansion resolutions only; +- `packages/coding-agent/npm-shrinkwrap.json`: the packed package edge plus those two resolutions only. + +It preserves Pi at 0.85.1, every unrelated pin, and Gondolin's nested `undici@6.28.0`. `brace-expansion@5.0.12` is the sole companion change because the full A5 report explicitly requires this remediation's resulting tree to meet that floor; Minimatch's existing `^5.0.5` edge is unchanged. It does not alter the esbuild script: that exact script already bundles non-external dependencies and rejects unexpected external imports. + +The stored patch has zero context so the repository's whitespace gate does not misread tab-indented JSON context lines as indentation errors. The driver uses `git apply --unidiff-zero`; exact full-file preimage hashes and the three-path allowlist are checked first. + +`packages/coding-agent/install-lock/package-lock.json` deliberately stays byte-identical and still describes the public registry's published Pi 0.85.1, including Undici 8.9.0 and brace-expansion 5.0.9. Rewriting that lock while its dependency still points at public `@earendil-works/pi-coding-agent@0.85.1` would be false evidence. Consequently, the broad upstream `npm run check` is not an admitted command: it includes `check:install-lock:coding-agent`. The recipe instead runs the truthful static/build checks individually and the upstream local-package consumer check. + +## Offline build and consumer source trace + +The exact checksum-verified Pi source archive, not fetched-code execution, establishes the future command boundary: + +- root `package.json` defines `build:offline` with the same workspace build sequence as `build`, except the AI workspace receives `npm run build:offline`; +- `packages/ai/package.json` defines normal `build` as `generate-models` followed by `build:offline`. `generate-models.ts` contains live catalog fetches to models.dev, NVIDIA, OpenRouter, and Vercel AI Gateway, so the driver does not use it; +- AI `build:offline` runs `check:model-data`, TypeScript compilation, and local file copies. `check-model-data.ts` and `model-data.ts` only read, parse, hash, and validate the committed generated catalog; they contain no provider/model call; +- every other workspace build in the root sequence is TypeScript compilation or a local copy/bundle operation. The unchanged esbuild script reads compiled local inputs and embeds non-allowlisted dependencies. + +The driver therefore uses upstream's supported `npm run build:offline`, sets npm's offline mode for that build command, and exports `PI_OFFLINE=1` and `PI_TELEMETRY=0`; it does not invent a build hook. The exact upstream `check:package-install` source runs local `npm pack --ignore-scripts`, creates a consumer whose public workspaces are local tarball overrides, and runs `npm install --ignore-scripts --omit=dev --no-audit --no-fund`. Its smoke subprocess imports three SDK symbols and runs the CLI and unbundled CLI with `--version` under an isolated HOME, `PI_OFFLINE=1`, and `PI_TELEMETRY=0`; it neither configures a model nor sends a provider request. The exact root lock and coding-agent shrinkwrap resolve remote packages only from `registry.npmjs.org`. Thus consumer installation remains a future networked CI step limited to the registry host, while its compatibility smoke is explicitly no-model. + +The future driver fetches build inputs only from `codeload.github.com`, `registry.npmjs.org`, and `nodejs.org`. `PIN.json` also records a read-only `raw.githubusercontent.com` npm metadata evidence URL, but the driver never fetches it; a three-host CI egress policy must continue to refuse that fourth host. + +## Evidence emitted by a future admitted run + +The driver validates the unpatched archive against exact preimage hashes, applies the three-file patch, and validates exact postimage hashes before package materialization. After a future build it validates the installed Undici package and four security-relevant source files against the 8.12.2 tarball hashes. + +It then opens the packed package and follows relative static/dynamic imports from the actual `package.json` `bin.pi` entry. The reachable closure must: + +- resolve `undici@8.12.2` and `brace-expansion@5.0.12` with exact npm integrities in the packed shrinkwrap; +- have no external `undici` import; +- contain the fixed `controller.abort(new Error(\`too many content-encodings...` signature; +- omit the vulnerable `reject(new Error(\`too many content-encodings...` signature; +- omit the known direct vulnerable assertion signatures equivalent to `assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID")`, including source and minified whitespace forms. + +Those are the two A5-002 advisory seams: GHSA-xjj6-f33q-929c (orphaned response stream after excessive content codings) and GHSA-qrxm-fqhp-jwvm (TLS alternate-name assertion termination). These conservative source signatures discriminate the fixed abort from the known vulnerable direct assertions; they are not a parser and do not prove the absence of every semantically equivalent JavaScript program. The recipe makes no broader security claim about Undici or Pi. + +On success, `src/receipts.mjs` writes deterministic, tab-formatted receipts: + +- `artifact-manifest.json`: artifact hashes, exact material/toolchain pins, actual bundle assertion results, and a sorted hash/mode inventory of every packed file; +- `sbom.spdx.json`: SPDX 2.3 packages and resolved dependency relationships derived from the packed shrinkwrap; +- `provenance.intoto.json`: an in-toto statement with SLSA provenance v1 predicate, source/package/tool/patch materials, and artifact/manifest/SBOM digests; +- `SHA256SUMS`: sorted hashes of the artifact and all three receipts. + +Dynamic wall-clock time, scratch paths, hostnames, and CI run IDs are excluded. `SOURCE_DATE_EPOCH` is the pinned upstream commit epoch. CI acceptance still requires two clean builds to prove the upstream build output itself is reproducible. + +## Executable instructions — UNRUN + +These commands are review material. Do not run them under the current authorization. + +```bash +# UNRUN — package preparation checks only; no fetched code or package installation. +cleanup_owned_scratch() { + case "$1" in + /mnt/storage/scratch/tmp/tmp.??????????) + [ -d "$1" ] && [ ! -L "$1" ] || return 2 + [ "$(stat -c %u -- "$1")" = "$(id -u)" ] || return 2 + [ "$(stat -c %a -- "$1")" = "700" ] || return 2 + find "$1" -depth -delete + ;; + *) return 2 ;; + esac +} +recipe_scratch="$(mktemp -d -p /mnt/storage/scratch/tmp)" +MOSAIC_PI_REBUILD_TEST_TMP="$recipe_scratch" \ + node --test packages/pi-rebuild/tests/recipe.test.mjs +node packages/pi-rebuild/src/cli.mjs validate-recipe +cleanup_owned_scratch "$recipe_scratch" +``` + +The full driver downloads only the exact pinned Pi source archive, Undici package, brace-expansion package, and Node toolchain; verifies them before use; clears the environment for every fetched-code command; uses an empty HOME, empty user/global npm configs, and owned cache; runs `npm ci --ignore-scripts`; and never publishes or changes Mosaic's dependency graph. + +```bash +# UNRUN — requires a separate parent-approved build/CI action. +cleanup_owned_scratch() { + case "$1" in + /mnt/storage/scratch/tmp/tmp.??????????) + [ -d "$1" ] && [ ! -L "$1" ] || return 2 + [ "$(stat -c %u -- "$1")" = "$(id -u)" ] || return 2 + [ "$(stat -c %a -- "$1")" = "700" ] || return 2 + find "$1" -depth -delete + ;; + *) return 2 ;; + esac +} +build_scratch="$(mktemp -d -p /mnt/storage/scratch/tmp)" +MOSAIC_PI_REBUILD_EXECUTE=MOS2248_REVIEWED_UNRUN_RECIPE \ + packages/pi-rebuild/rebuild.sh "$build_scratch" + +# Review/copy results before disposing only this owned directory. +find "$build_scratch/result" -maxdepth 1 -type f -print | sort +cleanup_owned_scratch "$build_scratch" +``` + +The driver additionally requires that `WORK_ROOT` resolve to an empty, non-symlink, mode-0700 directory owned by the invoking uid, directly under `/mnt/storage/scratch/tmp`, with the exact `tmp.` plus ten-character basename shape emitted by the required `mktemp` command. The guard value is only a fail-closed acknowledgement that the recipe was admitted; it is not release, adoption, publish, credential, provider, or spend authority. + +## Admitted CI acceptance — UNRUN + +A future job must have `permissions: contents: read`, no secrets, no npm token, no provider credentials, and outbound access restricted to the three build-input HTTPS hosts named above. It must not use `npm publish`, `npm install -g`, a container build, Mosaic release/activation commands, or any model/provider test. + +1. Run the three local recipe fixture tests and `validate-recipe`. This proves recipe/input validation, TLS signature discrimination, and deterministic receipt generation against synthetic fixtures only; it does not prove a rebuilt bundle fixed. +2. In two separately created `mktemp -d -p /mnt/storage/scratch/tmp` directories, run `rebuild.sh` with the admitted guard. Compare every same-named file in both `result/` directories byte-for-byte and compare both `SHA256SUMS` files. Any difference refuses. +3. In each result, run `sha256sum --check --strict SHA256SUMS`, unpack the package, and rerun `verify-bundle` against that package and the installed build-tree `node_modules/undici`. This is the actual consumed-bundle/fixed-signature gate. +4. Require the packed CLI and unbundled CLI to print exactly `0.85.1` under an empty HOME with `PI_OFFLINE=1` and `PI_TELEMETRY=0`, matching upstream `check:package-install`. No request is sent and no provider/model is configured. +5. In the Mosaic checkout, run only the no-model seams below. The Pi harness test executes a stand-in binary and fixes the adapter argv contract; the runner test uses its fake adapter/broker and fixes dispatch/publication behavior. Neither consumes credentials or contacts a provider. + +```bash +# UNRUN — focused Mosaic compatibility, not the full gate. +node --test packages/plugins/tests/harness.test.mjs +node --test packages/harness/tests/runner.test.mjs +``` + +6. Review the artifact manifest, SPDX dependency edges for `undici@8.12.2` and `brace-expansion@5.0.12`, provenance materials, and absence of changes outside this recipe directory plus append-only logs. Parent then records the CI URLs/hashes; this recipe does not do API, PR, CI, or release operations. + +## Remaining boundary + +No rebuilt artifact exists yet. Even after admitted CI succeeds, the result has the public npm identity and version `@earendil-works/pi-coding-agent@0.85.1` but different bytes. It cannot be published over that immutable npm version, and this task does not authorize Mosaic to adopt a local tarball or another package source. + +Jason must later choose one separately reviewed consumption boundary: + +1. approve an immutable internal/local artifact URL plus hash and the corresponding Mosaic `package.json`/lock adoption, while retaining the package's internal 0.85.1 identity; or +2. approve a fork/new package identity or version and its source/publish/release path. + +Either choice requires separate adoption, CI, release, and runtime-switch authority. No such choice is needed to review this source-ready recipe. + +The generated provenance and SBOM are deterministic but unsigned. A later trusted CI admission must bind their hashes to its authenticated run; this recipe does not claim a trusted-builder signature. diff --git a/packages/pi-rebuild/package.json b/packages/pi-rebuild/package.json new file mode 100644 index 000000000..431d4466f --- /dev/null +++ b/packages/pi-rebuild/package.json @@ -0,0 +1,13 @@ +{ + "name": "@mosaic/pi-rebuild-recipe", + "private": true, + "type": "module", + "description": "UNRUN source-only Pi 0.85.1 / Undici 8.12.2 rebuild recipe and evidence validators", + "engines": { + "node": "22.23.4" + }, + "scripts": { + "test": "node --test tests/*.test.mjs", + "validate": "node src/cli.mjs validate-recipe" + } +} diff --git a/packages/pi-rebuild/patches/pi-0.85.1-undici-8.12.2.patch b/packages/pi-rebuild/patches/pi-0.85.1-undici-8.12.2.patch new file mode 100644 index 000000000..bb3b6f462 --- /dev/null +++ b/packages/pi-rebuild/patches/pi-0.85.1-undici-8.12.2.patch @@ -0,0 +1,46 @@ +diff --git a/package-lock.json b/package-lock.json +--- a/package-lock.json ++++ b/package-lock.json +@@ -2523,3 +2523,3 @@ +- "version": "5.0.9", +- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", +- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", ++ "version": "5.0.12", ++ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", ++ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", +@@ -4979,3 +4979,3 @@ +- "version": "8.9.0", +- "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", +- "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", ++ "version": "8.12.2", ++ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", ++ "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", +@@ -5441 +5441 @@ +- "undici": "8.9.0", ++ "undici": "8.12.2", +diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json +--- a/packages/coding-agent/npm-shrinkwrap.json ++++ b/packages/coding-agent/npm-shrinkwrap.json +@@ -29 +29 @@ +- "undici": "8.9.0", ++ "undici": "8.12.2", +@@ -1432,3 +1432,3 @@ +- "version": "5.0.9", +- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", +- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", ++ "version": "5.0.12", ++ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", ++ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", +@@ -2144,3 +2144,3 @@ +- "version": "8.9.0", +- "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", +- "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", ++ "version": "8.12.2", ++ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz", ++ "integrity": "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==", +diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json +--- a/packages/coding-agent/package.json ++++ b/packages/coding-agent/package.json +@@ -69 +69 @@ +- "undici": "8.9.0", ++ "undici": "8.12.2", diff --git a/packages/pi-rebuild/rebuild.sh b/packages/pi-rebuild/rebuild.sh new file mode 100755 index 000000000..37a27278b --- /dev/null +++ b/packages/pi-rebuild/rebuild.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# UNRUN: this source-only driver is prepared for a separately authorized CI run. +# It intentionally refuses unless the caller supplies the Mos2248 review token. +set -euo pipefail + +fail() { echo "pi rebuild refused: $*" >&2; exit 2; } + +[ "$#" -eq 1 ] || fail "usage: rebuild.sh WORK_ROOT" +[ "${MOSAIC_PI_REBUILD_EXECUTE:-}" = "MOS2248_REVIEWED_UNRUN_RECIPE" ] || fail "set MOSAIC_PI_REBUILD_EXECUTE=MOS2248_REVIEWED_UNRUN_RECIPE only in the admitted rebuild job" + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)" +work_root="$1" +[ -d "$work_root" ] && [ ! -L "$work_root" ] || fail "WORK_ROOT must be the non-symlink directory returned by mktemp" +work_root="$(realpath -- "$work_root")" +[ "$(dirname -- "$work_root")" = "/mnt/storage/scratch/tmp" ] || fail "WORK_ROOT must be created by mktemp -d -p /mnt/storage/scratch/tmp" +[[ "$(basename -- "$work_root")" =~ ^tmp\.[A-Za-z0-9]{10}$ ]] || fail "WORK_ROOT name must match the exact mktemp -d -p /mnt/storage/scratch/tmp result" +[ "$(/usr/bin/stat -c %u -- "$work_root")" = "$(/usr/bin/id -u)" ] || fail "WORK_ROOT must be owned by the invoking user" +[ "$(/usr/bin/stat -c %a -- "$work_root")" = "700" ] || fail "WORK_ROOT permissions must be 0700" +[ -z "$(find "$work_root" -mindepth 1 -maxdepth 1 -print -quit)" ] || fail "WORK_ROOT must start empty" + +pin_file="$script_dir/PIN.json" +patch_file="$script_dir/patches/pi-0.85.1-undici-8.12.2.patch" +system_path="/usr/local/bin:/usr/bin:/bin" + +pin() { + /usr/bin/env -i PATH="$system_path" /usr/bin/node -e ' +const value = process.argv[2].split(".").reduce((v, key) => v?.[key], JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))); +if (value === undefined || (typeof value === "object" && value !== null)) process.exit(2); +process.stdout.write(String(value)); +' "$pin_file" "$1" +} + +mkdir -m 0700 -- "$work_root/inputs" "$work_root/home" "$work_root/npm-cache" "$work_root/source-parent" "$work_root/pack" "$work_root/package-root" "$work_root/tmp" +install -m 0600 /dev/null "$work_root/npmrc" +install -m 0600 /dev/null "$work_root/npm-globalrc" + +pi_archive="$work_root/inputs/pi-source.tar.gz" +undici_archive="$work_root/inputs/undici-8.12.2.tgz" +brace_archive="$work_root/inputs/brace-expansion-5.0.12.tgz" +node_archive="$work_root/inputs/node-v22.23.4-linux-x64.tar.xz" + +fetch() { + url="$1" + output="$2" + /usr/bin/env -i PATH="$system_path" HOME="$work_root/home" \ + curl --proto '=https' --tlsv1.2 --location --fail --silent --show-error --output "$output" "$url" +} + +fetch "$(pin pi.source.url)" "$pi_archive" +fetch "$(pin undici.tarball.url)" "$undici_archive" +fetch "$(pin braceExpansion.tarball.url)" "$brace_archive" +fetch "$(pin toolchain.node.url)" "$node_archive" + +check_file() { + path="$1" + expected_bytes="$2" + expected_sha256="$3" + [ "$(stat -c %s -- "$path")" = "$expected_bytes" ] || fail "byte length mismatch: $path" + printf '%s %s\n' "$expected_sha256" "$path" | sha256sum --check --strict --status || fail "sha256 mismatch: $path" +} + +check_file "$pi_archive" "$(pin pi.source.bytes)" "$(pin pi.source.sha256)" +check_file "$undici_archive" "$(pin undici.tarball.bytes)" "$(pin undici.tarball.sha256)" +check_file "$brace_archive" "$(pin braceExpansion.tarball.bytes)" "$(pin braceExpansion.tarball.sha256)" +printf '%s %s\n' "$(pin pi.source.sha512)" "$pi_archive" | sha512sum --check --strict --status || fail "Pi source sha512 mismatch" +printf '%s %s\n' "$(pin undici.tarball.sha512)" "$undici_archive" | sha512sum --check --strict --status || fail "Undici sha512 mismatch" +printf '%s %s\n' "$(pin braceExpansion.tarball.sha512)" "$brace_archive" | sha512sum --check --strict --status || fail "brace-expansion sha512 mismatch" +printf '%s %s\n' "$(pin toolchain.node.sha256)" "$node_archive" | sha256sum --check --strict --status || fail "Node archive sha256 mismatch" + +archive_root="$(pin pi.source.archiveRoot)" +tar -tzf "$pi_archive" > "$work_root/pi-members" +while IFS= read -r member; do + case "$member" in + "$archive_root"|"$archive_root"/*) ;; + *) fail "Pi source archive path escapes its pinned root: $member" ;; + esac + case "/$member/" in *"/../"*) fail "Pi source archive contains a parent path: $member" ;; esac +done < "$work_root/pi-members" +tar -tvzf "$pi_archive" | awk '$1 !~ /^[-d]/ { exit 1 }' || fail "Pi source archive contains a link or special entry" +tar -xzf "$pi_archive" -C "$work_root/source-parent" +source_root="$work_root/source-parent/$archive_root" + +tar -xJf "$node_archive" -C "$work_root" +node_root="$work_root/node-v22.23.4-linux-x64" +node_bin="$node_root/bin/node" +npm_bin="$node_root/bin/npm" + +build_path="$node_root/bin:/usr/local/bin:/usr/bin:/bin" +node_env=( + /usr/bin/env -i + PATH="$build_path" + HOME="$work_root/home" + XDG_CACHE_HOME="$work_root/home/cache" + TMPDIR="$work_root/tmp" + LANG=C.UTF-8 + LC_ALL=C.UTF-8 + TZ=UTC + CI=1 + SOURCE_DATE_EPOCH="$(pin pi.sourceDateEpoch)" + npm_config_cache="$work_root/npm-cache" + npm_config_userconfig="$work_root/npmrc" + npm_config_globalconfig="$work_root/npm-globalrc" + npm_config_registry=https://registry.npmjs.org + npm_config_audit=false + npm_config_fund=false + npm_config_update_notifier=false + PI_OFFLINE=1 + PI_TELEMETRY=0 +) + +# Even the first fetched-tool version probes use the closed build environment. +[ "$("${node_env[@]}" "$node_bin" --version)" = "v$(pin toolchain.node.version)" ] || fail "Node executable version mismatch" +[ "$("${node_env[@]}" "$npm_bin" --version)" = "$(pin toolchain.npm.version)" ] || fail "npm executable version mismatch" + +"${node_env[@]}" "$node_bin" "$script_dir/src/cli.mjs" validate-recipe --source-root "$source_root" +git -C "$source_root" apply --unidiff-zero --check "$patch_file" +git -C "$source_root" apply --unidiff-zero "$patch_file" +"${node_env[@]}" "$node_bin" "$script_dir/src/cli.mjs" validate-patched --source-root "$source_root" + +# Network materialization is lock/integrity constrained; package lifecycle scripts stay disabled. +(cd "$source_root" && "${node_env[@]}" "$npm_bin" ci --ignore-scripts --no-audit --no-fund) + +# These are the source/build checks that remain truthful with the registry install-lock intentionally unchanged. +(cd "$source_root" && "${node_env[@]}" npm_config_offline=true "$npm_bin" run build:offline) +(cd "$source_root" && "${node_env[@]}" "$npm_bin" run check:pinned-deps) +(cd "$source_root" && "${node_env[@]}" "$npm_bin" run check:runtime-deps) +(cd "$source_root" && "${node_env[@]}" "$npm_bin" run check:ts-imports) +(cd "$source_root" && "${node_env[@]}" "$npm_bin" run check:entry-graphs) +(cd "$source_root" && "${node_env[@]}" "$npm_bin" run check:shrinkwrap) +(cd "$source_root" && "${node_env[@]}" "$source_root/node_modules/.bin/tsgo" --noEmit) +(cd "$source_root" && "${node_env[@]}" "$npm_bin" run check:browser-smoke) +(cd "$source_root" && "${node_env[@]}" "$npm_bin" run check:package-install) + +pack_json="$(cd "$source_root/packages/coding-agent" && "${node_env[@]}" "$npm_bin" pack --ignore-scripts --json --pack-destination "$work_root/pack")" +artifact_name="$("${node_env[@]}" "$node_bin" -e ' +const parsed = JSON.parse(process.argv[1]); +const entry = Array.isArray(parsed) ? parsed[0] : Object.values(parsed)[0]; +if (!entry?.filename || /[\\/]/.test(entry.filename)) process.exit(2); +process.stdout.write(entry.filename); +' "$pack_json")" || fail "npm pack did not return one safe filename" +artifact="$work_root/pack/$artifact_name" +[ -f "$artifact" ] && [ ! -L "$artifact" ] || fail "packed artifact is absent or unsafe" + +tar -tzf "$artifact" | awk '$0 !~ /^package\// || /(^|\/)\.\.($|\/)/ { exit 1 }' || fail "packed artifact contains an unsafe member" +tar -xzf "$artifact" -C "$work_root/package-root" +package_root="$work_root/package-root/package" + +"${node_env[@]}" "$node_bin" "$script_dir/src/cli.mjs" verify-bundle \ + --package-root "$package_root" \ + --installed-undici-root "$source_root/node_modules/undici" + +mkdir -m 0700 "$work_root/result" +"${node_env[@]}" MOSAIC_PI_REBUILD_NPM_VERSION="$(pin toolchain.npm.version)" \ + "$node_bin" "$script_dir/src/cli.mjs" write-receipts \ + --artifact "$artifact" \ + --package-root "$package_root" \ + --installed-undici-root "$source_root/node_modules/undici" \ + --output "$work_root/result" +install -m 0600 "$artifact" "$work_root/result/$artifact_name" +(cd "$work_root/result" && sha256sum --check --strict SHA256SUMS) + +printf 'UNPUBLISHED source rebuild prepared at %s\n' "$work_root/result" diff --git a/packages/pi-rebuild/src/cli.mjs b/packages/pi-rebuild/src/cli.mjs new file mode 100755 index 000000000..365eead3c --- /dev/null +++ b/packages/pi-rebuild/src/cli.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + assertBundleConsumption, + validatePatch, + validatePatchedTree, + validatePin, + validateSourceTree, +} from "./recipe.mjs"; +import { writeReceipts } from "./receipts.mjs"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const pin = JSON.parse(readFileSync(join(packageRoot, "PIN.json"), "utf8")); +const patch = readFileSync(join(packageRoot, pin.patch.path), "utf8"); + +function usage(message) { + if (message) process.stderr.write(`${message}\n`); + process.stderr.write("usage: cli.mjs validate-recipe [--source-root DIR] | validate-patched --source-root DIR | verify-bundle --package-root DIR --installed-undici-root DIR | write-receipts --artifact FILE --package-root DIR --installed-undici-root DIR --output DIR\n"); + process.exit(2); +} + +function options(args, allowed) { + const parsed = {}; + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name?.startsWith("--") || value === undefined || value.startsWith("--")) usage(`invalid option sequence at ${name ?? ""}`); + const key = name.slice(2); + if (!allowed.has(key) || parsed[key] !== undefined) usage(`unknown or repeated option: ${name}`); + parsed[key] = resolve(value); + } + return parsed; +} + +function requireOptions(parsed, names) { + for (const name of names) if (!parsed[name]) usage(`--${name} is required`); +} + +function print(value) { + process.stdout.write(`${JSON.stringify(value, null, "\t")}\n`); +} + +const [command, ...args] = process.argv.slice(2); +try { + validatePin(pin); + const patchEvidence = validatePatch(patch, pin); + switch (command) { + case "validate-recipe": { + const parsed = options(args, new Set(["source-root"])); + const source = parsed["source-root"] ? validateSourceTree(parsed["source-root"], pin) : undefined; + print({ state: pin.state, pin: "valid", patch: patchEvidence, ...(source ? { source } : {}) }); + break; + } + case "validate-patched": { + const parsed = options(args, new Set(["source-root"])); + requireOptions(parsed, ["source-root"]); + print({ state: pin.state, patchedSource: validatePatchedTree(parsed["source-root"], pin) }); + break; + } + case "verify-bundle": { + const parsed = options(args, new Set(["package-root", "installed-undici-root"])); + requireOptions(parsed, ["package-root", "installed-undici-root"]); + print({ state: pin.state, bundle: assertBundleConsumption(parsed["package-root"], pin, { installedUndiciRoot: parsed["installed-undici-root"] }) }); + break; + } + case "write-receipts": { + const parsed = options(args, new Set(["artifact", "package-root", "installed-undici-root", "output"])); + requireOptions(parsed, ["artifact", "package-root", "installed-undici-root", "output"]); + const npmVersion = process.env.MOSAIC_PI_REBUILD_NPM_VERSION; + if (!npmVersion) throw new Error("MOSAIC_PI_REBUILD_NPM_VERSION is required"); + print({ state: pin.state, receipts: writeReceipts({ + artifactPath: parsed.artifact, + packageRoot: parsed["package-root"], + installedUndiciRoot: parsed["installed-undici-root"], + outputDirectory: parsed.output, + pin, + toolVersions: { node: process.version, npm: npmVersion }, + }) }); + break; + } + default: + usage(command ? `unknown command: ${command}` : "command is required"); + } +} catch (error) { + process.stderr.write(`${error?.stack ?? error}\n`); + process.exit(1); +} diff --git a/packages/pi-rebuild/src/receipts.mjs b/packages/pi-rebuild/src/receipts.mjs new file mode 100644 index 000000000..9af8650fc --- /dev/null +++ b/packages/pi-rebuild/src/receipts.mjs @@ -0,0 +1,242 @@ +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join, posix, relative, resolve, sep } from "node:path"; + +import { assertBundleConsumption, constants } from "./recipe.mjs"; + +function refuse(condition, message) { + if (!condition) throw new Error(`pi rebuild receipts refused: ${message}`); +} + +function hashBuffer(contents, algorithm = "sha256") { + return createHash(algorithm).update(contents).digest("hex"); +} + +function fileRecord(path) { + const stat = lstatSync(path); + refuse(stat.isFile() && !stat.isSymbolicLink(), `not a regular non-symlink file: ${path}`); + const contents = readFileSync(path); + return { bytes: contents.length, sha256: hashBuffer(contents), sha512: hashBuffer(contents, "sha512") }; +} + +function jsonFile(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, "\t")}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 }); +} + +function walk(root, directory = root, records = []) { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const path = join(directory, entry.name); + refuse(!entry.isSymbolicLink(), `package inventory refuses symlink: ${path}`); + if (entry.isDirectory()) walk(root, path, records); + else { + refuse(entry.isFile(), `package inventory refuses non-file: ${path}`); + const record = fileRecord(path); + record.path = relative(root, path).split(sep).join("/"); + record.mode = (lstatSync(path).mode & 0o777).toString(8).padStart(4, "0"); + records.push(record); + } + } + return records.sort((a, b) => a.path.localeCompare(b.path)); +} + +function packageName(lockPath, entry, rootManifest) { + if (lockPath === "") return rootManifest.name; + if (entry.name) return entry.name; + const marker = "node_modules/"; + const index = lockPath.lastIndexOf(marker); + refuse(index >= 0, `cannot derive package name from shrinkwrap path ${lockPath}`); + const parts = lockPath.slice(index + marker.length).split("/"); + return parts[0].startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0]; +} + +function spdxId(lockPath) { + return `SPDXRef-Package-${hashBuffer(lockPath || "root").slice(0, 24)}`; +} + +function integrityChecksum(integrity) { + if (typeof integrity !== "string") return undefined; + const match = integrity.match(/^sha512-(.+)$/); + if (!match) return undefined; + return { algorithm: "SHA512", checksumValue: Buffer.from(match[1], "base64").toString("hex") }; +} + +function dependencyNames(entry) { + return Object.keys({ ...(entry.dependencies ?? {}), ...(entry.optionalDependencies ?? {}) }).sort(); +} + +function resolveDependency(packages, name, from) { + let current = from; + const candidates = []; + while (true) { + candidates.push(current ? `${current}/node_modules/${name}` : `node_modules/${name}`); + if (!current) break; + const parent = posix.dirname(current); + current = parent === "." || parent === current ? "" : parent; + } + return candidates.find((candidate) => packages[candidate] && !packages[candidate].link); +} + +function createSpdx(packageRoot, artifact, pin) { + const manifest = jsonFile(join(packageRoot, "package.json")); + const lock = jsonFile(join(packageRoot, "npm-shrinkwrap.json")); + const entries = Object.entries(lock.packages ?? {}) + .filter(([, entry]) => !entry.link) + .sort(([a], [b]) => a.localeCompare(b)); + const packages = entries.map(([lockPath, entry]) => { + const name = packageName(lockPath, entry, manifest); + const version = entry.version ?? manifest.version; + const checksum = integrityChecksum(entry.integrity); + return { + SPDXID: spdxId(lockPath), + name, + versionInfo: version, + downloadLocation: entry.resolved ?? "NOASSERTION", + filesAnalyzed: false, + licenseConcluded: "NOASSERTION", + licenseDeclared: entry.license ?? "NOASSERTION", + copyrightText: "NOASSERTION", + ...(checksum ? { checksums: [checksum] } : {}), + externalRefs: [{ + referenceCategory: "PACKAGE-MANAGER", + referenceType: "purl", + referenceLocator: `pkg:npm/${encodeURIComponent(name).replaceAll("%2F", "/")}@${encodeURIComponent(version)}`, + }], + comment: `npm shrinkwrap path: ${lockPath || ""}`, + }; + }); + const relationships = [{ + spdxElementId: "SPDXRef-DOCUMENT", + relationshipType: "DESCRIBES", + relatedSpdxElement: spdxId(""), + }]; + for (const [lockPath, entry] of entries) { + for (const name of dependencyNames(entry)) { + const target = resolveDependency(lock.packages, name, lockPath); + refuse(target, `cannot resolve ${name} from shrinkwrap path ${lockPath || ""}`); + relationships.push({ + spdxElementId: spdxId(lockPath), + relationshipType: "DEPENDS_ON", + relatedSpdxElement: spdxId(target), + }); + } + } + relationships.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + return { + spdxVersion: "SPDX-2.3", + dataLicense: "CC0-1.0", + SPDXID: "SPDXRef-DOCUMENT", + name: `${manifest.name}@${manifest.version}-undici-${pin.undici.version}`, + documentNamespace: `https://mosaicstack.invalid/spdx/pi-rebuild/${artifact.sha256}`, + creationInfo: { + created: pin.pi.commitTimestamp, + creators: ["Tool: @mosaic/pi-rebuild-recipe"], + }, + documentDescribes: [spdxId("")], + packages, + relationships, + }; +} + +function createManifest(packageRoot, artifactPath, pin, evidence, toolVersions) { + const artifact = fileRecord(artifactPath); + return { + schemaVersion: 1, + identity: { + name: constants.piName, + version: constants.piVersion, + undici: constants.undiciVersion, + braceExpansion: constants.braceExpansionVersion, + }, + artifact: { file: basename(artifactPath), ...artifact }, + inputs: { + piSource: { url: pin.pi.source.url, sha256: pin.pi.source.sha256, sha512: pin.pi.source.sha512 }, + undiciTarball: { url: pin.undici.tarball.url, sha256: pin.undici.tarball.sha256, sha512: pin.undici.tarball.sha512 }, + braceExpansionTarball: { url: pin.braceExpansion.tarball.url, sha256: pin.braceExpansion.tarball.sha256, sha512: pin.braceExpansion.tarball.sha512 }, + patch: { path: pin.patch.path, sha256: pin.patch.sha256 }, + node: { url: pin.toolchain.node.url, sha256: pin.toolchain.node.sha256 }, + }, + toolchain: { + node: toolVersions.node, + npm: toolVersions.npm, + esbuild: pin.toolchain.esbuild.version, + platform: pin.toolchain.platform, + }, + consumedBundle: evidence, + packageFiles: walk(packageRoot), + }; +} + +function createProvenance(artifactPath, artifact, manifestHash, sbomHash, pin, toolVersions) { + return { + _type: "https://in-toto.io/Statement/v1", + subject: [{ + name: basename(artifactPath), + digest: { sha256: artifact.sha256, sha512: artifact.sha512 }, + }], + predicateType: "https://slsa.dev/provenance/v1", + predicate: { + buildDefinition: { + buildType: "https://mosaicstack.invalid/buildtypes/pi-source-rebuild/v1", + externalParameters: { + pi: { name: constants.piName, version: constants.piVersion, commit: pin.pi.commit }, + undici: { version: constants.undiciVersion }, + braceExpansion: { version: constants.braceExpansionVersion }, + sourceDateEpoch: pin.pi.sourceDateEpoch, + }, + internalParameters: { + patchSha256: pin.patch.sha256, + manifestSha256: manifestHash, + sbomSha256: sbomHash, + toolchain: toolVersions, + }, + resolvedDependencies: [ + { uri: pin.pi.source.url, digest: { sha256: pin.pi.source.sha256, sha512: pin.pi.source.sha512 } }, + { uri: pin.undici.tarball.url, digest: { sha256: pin.undici.tarball.sha256, sha512: pin.undici.tarball.sha512 } }, + { uri: pin.braceExpansion.tarball.url, digest: { sha256: pin.braceExpansion.tarball.sha256, sha512: pin.braceExpansion.tarball.sha512 } }, + { uri: pin.toolchain.node.url, digest: { sha256: pin.toolchain.node.sha256 } }, + { uri: `file:${pin.patch.path}`, digest: { sha256: pin.patch.sha256 } }, + { uri: "file:package-lock.json", digest: { sha256: pin.sourcePostimages["package-lock.json"] } }, + ], + }, + runDetails: { + builder: { id: "https://mosaicstack.invalid/builders/pi-source-rebuild/v1" }, + metadata: { reproducibleEpoch: pin.pi.sourceDateEpoch }, + }, + }, + }; +} + +export function writeReceipts({ artifactPath, packageRoot, installedUndiciRoot, outputDirectory, pin, toolVersions }) { + refuse(toolVersions?.node === `v${pin.toolchain.node.version}`, `Node must be v${pin.toolchain.node.version}`); + refuse(toolVersions?.npm === pin.toolchain.npm.version, `npm must be ${pin.toolchain.npm.version}`); + const artifact = fileRecord(artifactPath); + const evidence = assertBundleConsumption(packageRoot, pin, { installedUndiciRoot }); + const output = resolve(outputDirectory); + if (existsSync(output)) refuse(readdirSync(output).length === 0, `receipt directory is not empty: ${output}`); + else mkdirSync(output, { recursive: false, mode: 0o700 }); + + const manifest = createManifest(packageRoot, artifactPath, pin, evidence, toolVersions); + const sbom = createSpdx(packageRoot, artifact, pin); + const manifestPath = join(output, "artifact-manifest.json"); + const sbomPath = join(output, "sbom.spdx.json"); + writeJson(manifestPath, manifest); + writeJson(sbomPath, sbom); + const manifestHash = fileRecord(manifestPath).sha256; + const sbomHash = fileRecord(sbomPath).sha256; + const provenance = createProvenance(artifactPath, artifact, manifestHash, sbomHash, pin, toolVersions); + const provenancePath = join(output, "provenance.intoto.json"); + writeJson(provenancePath, provenance); + + const records = [ + [artifactPath, artifact.sha256], + [manifestPath, manifestHash], + [sbomPath, sbomHash], + [provenancePath, fileRecord(provenancePath).sha256], + ].map(([path, sha256]) => ({ file: basename(path), sha256 })).sort((a, b) => a.file.localeCompare(b.file)); + writeFileSync(join(output, "SHA256SUMS"), `${records.map(({ file, sha256 }) => `${sha256} ${file}`).join("\n")}\n`, { flag: "wx", mode: 0o600 }); + return { artifact, evidence, files: records }; +} diff --git a/packages/pi-rebuild/src/recipe.mjs b/packages/pi-rebuild/src/recipe.mjs new file mode 100644 index 000000000..e11cb414d --- /dev/null +++ b/packages/pi-rebuild/src/recipe.mjs @@ -0,0 +1,560 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +const PI_NAME = "@earendil-works/pi-coding-agent"; +const PI_VERSION = "0.85.1"; +const PI_COMMIT = "d981de1229ef899957bbe968bc8dcda02a21f477"; +const UNDICI_OLD = "8.9.0"; +const UNDICI_FIXED = "8.12.2"; +const UNDICI_URL = "https://registry.npmjs.org/undici/-/undici-8.12.2.tgz"; +const UNDICI_INTEGRITY = "sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg=="; +const BRACE_OLD = "5.0.9"; +const BRACE_FIXED = "5.0.12"; +const BRACE_URL = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz"; +const BRACE_INTEGRITY = "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="; +const PATCH_TARGETS = [ + "package-lock.json", + "packages/coding-agent/npm-shrinkwrap.json", + "packages/coding-agent/package.json", +]; +const ALLOWED_HOSTS = new Set(["codeload.github.com", "nodejs.org", "raw.githubusercontent.com", "registry.npmjs.org"]); + +function refuse(condition, message) { + if (!condition) throw new Error(`pi rebuild recipe refused: ${message}`); +} + +function at(object, path) { + return path.split(".").reduce((value, key) => value?.[key], object); +} + +function expect(pin, path, expected) { + const actual = at(pin, path); + refuse(actual === expected, `${path} must be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} + +function expectHash(value, length, label) { + refuse(typeof value === "string" && new RegExp(`^[0-9a-f]{${length}}$`).test(value), `${label} is not a lowercase ${length}-hex digest`); +} + +function validateUrl(value, expected, label) { + expect({ value }, "value", expected); + const url = new URL(value); + refuse(url.protocol === "https:", `${label} must use HTTPS`); + refuse(!url.username && !url.password && !url.hash, `${label} must not contain userinfo or a fragment`); + refuse(ALLOWED_HOSTS.has(url.hostname), `${label} host is not approved: ${url.hostname}`); +} + +function json(path) { + const stat = lstatSync(path); + refuse(stat.isFile() && !stat.isSymbolicLink(), `JSON input is not a regular non-symlink file: ${path}`); + return JSON.parse(readFileSync(path, "utf8")); +} + +function digest(contents, algorithm = "sha256") { + return createHash(algorithm).update(contents).digest("hex"); +} + +function fileDigest(path, algorithm = "sha256") { + const stat = lstatSync(path); + refuse(stat.isFile() && !stat.isSymbolicLink(), `not a regular non-symlink file: ${path}`); + return digest(readFileSync(path), algorithm); +} + +function safePath(root, path) { + refuse(typeof path === "string" && path.length > 0 && !isAbsolute(path), `unsafe relative path: ${path}`); + const target = resolve(root, path); + const base = `${resolve(root)}${sep}`; + refuse(target.startsWith(base), `path escapes its root: ${path}`); + return target; +} + +function validateDigestMap(root, map, label) { + refuse(map && typeof map === "object" && !Array.isArray(map), `${label} must be an object`); + for (const [path, expected] of Object.entries(map)) { + expectHash(expected, 64, `${label}.${path}`); + const actual = fileDigest(safePath(root, path)); + refuse(actual === expected, `${label} sha256 preimage mismatch for ${path}: expected ${expected}, got ${actual}`); + } +} + +function directUndici(lock) { + return lock?.packages?.["node_modules/undici"]; +} + +function directBraceExpansion(lock) { + return lock?.packages?.["node_modules/brace-expansion"]; +} + +function codingWorkspace(lock) { + return lock?.packages?.["packages/coding-agent"]; +} + +function shrinkwrapRoot(lock) { + return lock?.packages?.[""]; +} + +function assertUndiciResolution(entry, version, url, integrity, label) { + refuse(entry?.version === version, `${label} version must be ${version}`); + if (url !== undefined) refuse(entry?.resolved === url, `${label} resolved URL must be ${url}`); + if (integrity !== undefined) refuse(entry?.integrity === integrity, `${label} integrity must be ${integrity}`); +} + +export function validatePin(pin) { + refuse(pin && typeof pin === "object" && !Array.isArray(pin), "PIN must be an object"); + expect(pin, "schemaVersion", 1); + expect(pin, "state", "UNRUN"); + expect(pin, "ruling", "Mos2248"); + expect(pin, "pi.name", PI_NAME); + expect(pin, "pi.version", PI_VERSION); + expect(pin, "pi.tag", "v0.85.1"); + expect(pin, "pi.commit", PI_COMMIT); + expect(pin, "pi.tree", "346294a615d2d0ad4f6e5fbccb4cee4ccd7b2d6c"); + expect(pin, "pi.commitTimestamp", "2026-09-05T11:54:46Z"); + expect(pin, "pi.sourceDateEpoch", 1788609286); + validateUrl(pin.pi?.source?.url, `https://codeload.github.com/earendil-works/pi/tar.gz/${PI_COMMIT}`, "Pi source URL"); + expect(pin, "pi.source.archiveRoot", `pi-${PI_COMMIT}`); + expect(pin, "pi.source.bytes", 6978387); + expect(pin, "pi.source.sha256", "d2a04b35e57f59873d67f667f22e681e93c78a46ba8e2cdedf9d4c323b1ae8dc"); + expect(pin, "pi.source.sha512", "77acb7819e75da17059cb507064739d6eaf033ad76b9a5d2b090cb607bf97ddff5a94ec0ae57ce5f1c168c95f370b6e97513e667fdb47b53ed55f486286a2ae9"); + validateUrl(pin.pi?.registryReference?.metadataUrl, "https://registry.npmjs.org/@earendil-works%2fpi-coding-agent/0.85.1", "Pi registry metadata URL"); + validateUrl(pin.pi?.registryReference?.tarballUrl, "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz", "Pi registry tarball URL"); + expect(pin, "pi.registryReference.gitHead", PI_COMMIT); + expect(pin, "pi.registryReference.integrity", "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ=="); + expect(pin, "pi.registryReference.tarballSha256", "1f498729649bdce647d1160993b4d92bf3c614cc819213bee2f91dd34f2a7af4"); + expect(pin, "pi.registryReference.tarballSha512", "14644df8e1db59a79f04f19a4e080074b8eb2075beb363f32f2825cffe5d7cbcdbf687fbbae5cc5d80b47c921e653c3eb214b7da8d9cb90f6317b6120ee2ffa1"); + validateUrl(pin.pi?.registryReference?.attestationsUrl, "https://registry.npmjs.org/-/npm/v1/attestations/@earendil-works%2fpi-coding-agent@0.85.1", "Pi attestations URL"); + + expect(pin, "undici.name", "undici"); + expect(pin, "undici.version", UNDICI_FIXED); + expect(pin, "undici.gitHead", "bb6cc0ae9b6827ed273ed5ba344148273629638f"); + validateUrl(pin.undici?.metadata?.url, "https://registry.npmjs.org/undici/8.12.2", "Undici metadata URL"); + validateUrl(pin.undici?.tarball?.url, UNDICI_URL, "Undici tarball URL"); + expect(pin, "undici.tarball.bytes", 542130); + expect(pin, "undici.tarball.sha256", "a765b5e63ab5c6b1b91a59072c88f7857b36f47b09c376762241d177279cdefd"); + expect(pin, "undici.tarball.sha512", "5edb8359576a4cbaf7340c66da5cbdfe153a914b093a01a1d51012ccc05bb30e9a58239a4331dd5be4100df7444637c9d95e6f2f9a513dae117ad3bd51f8af0e"); + expect(pin, "undici.tarball.integrity", UNDICI_INTEGRITY); + validateUrl(pin.undici?.attestations?.url, "https://registry.npmjs.org/-/npm/v1/attestations/undici@8.12.2", "Undici attestations URL"); + expect(pin, "braceExpansion.name", "brace-expansion"); + expect(pin, "braceExpansion.version", BRACE_FIXED); + expect(pin, "braceExpansion.gitHead", "f3410159d768f56c9d9f4511d3e1b46425fc1099"); + validateUrl(pin.braceExpansion?.metadata?.url, "https://registry.npmjs.org/brace-expansion/5.0.12", "brace-expansion metadata URL"); + expect(pin, "braceExpansion.metadata.sha256", "5421e68ea58a0286041e3f11998da4fa44055026b11c527cc14ec74895b58929"); + validateUrl(pin.braceExpansion?.tarball?.url, BRACE_URL, "brace-expansion tarball URL"); + expect(pin, "braceExpansion.tarball.bytes", 12620); + expect(pin, "braceExpansion.tarball.sha256", "ef8448ec78f20b692f04fa6d01f39b5ab34c66404bea3429f5a39c6c9e0be8b4"); + expect(pin, "braceExpansion.tarball.sha512", "628bd0debce168b308ac38cd0cc90d4b4d6d79af77aa11211b9c72f1febe47497e770dca8bee6c0a822823de368ae2d94c75a881692d830543854d3d88d12299"); + expect(pin, "braceExpansion.tarball.integrity", BRACE_INTEGRITY); + + expect(pin, "toolchain.platform", "linux-x64"); + expect(pin, "toolchain.node.version", "22.23.4"); + validateUrl(pin.toolchain?.node?.url, "https://nodejs.org/dist/v22.23.4/node-v22.23.4-linux-x64.tar.xz", "Node archive URL"); + expect(pin, "toolchain.node.sha256", "1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce"); + validateUrl(pin.toolchain?.node?.releaseUrl, "https://nodejs.org/en/blog/release/v22.23.4", "Node release URL"); + validateUrl(pin.toolchain?.node?.securityReleaseUrl, "https://nodejs.org/en/blog/vulnerability/october-2026-security-releases", "Node security release URL"); + validateUrl(pin.toolchain?.node?.shasumsUrl, "https://nodejs.org/dist/v22.23.4/SHASUMS256.txt", "Node SHASUMS URL"); + expect(pin, "toolchain.node.shasumsSha256", "c90c3f1478fda8308b22cc1a1b0dc536169196444e67ee8b5c582fd351d02bce"); + expect(pin, "toolchain.node.pgpSignatureVerified", false); + expect(pin, "toolchain.npm.version", "10.9.9"); + validateUrl(pin.toolchain?.npm?.packageJsonUrl, "https://raw.githubusercontent.com/nodejs/node/v22.23.4/deps/npm/package.json", "bundled npm package metadata URL"); + expect(pin, "toolchain.npm.packageJsonSha256", "7c04bf69824d17a1db64defd3a9a86d538781bf2d6ca2764e3f1143fa5b71166"); + expect(pin, "toolchain.esbuild.version", "0.28.1"); + + expect(pin, "patch.path", "patches/pi-0.85.1-undici-8.12.2.patch"); + expect(pin, "patch.sha256", "6f1c3aaf5c6bdf08cfb02932d77251d8048130ba59c63a099b25cae26cb0bf0e"); + refuse(JSON.stringify(pin.patch?.allowedTargets) === JSON.stringify(PATCH_TARGETS), "patch target allowlist changed"); + expect(pin, "securityEvidence.briefSha256", "83b9462ba40e93934fcf3cc96617f59dff3950d4321e3351eb49ab83a03c1359"); + expect(pin, "securityEvidence.a5ReportSha256", "71270a41a957921b5a83b09650f817fdaef31ef4e762f03a68c8eed8207fb307"); + expect(pin, "securityEvidence.a5MetadataSha256", "4ae5158491a25d99778fa84cefe65260c0d6aed701b6e710de2c80ba8e39877f"); + + for (const [label, value] of [ + ["Pi metadata", pin.pi?.registryReference?.metadataSha256], + ["Pi attestations", pin.pi?.registryReference?.attestationsSha256], + ["Undici metadata", pin.undici?.metadata?.sha256], + ["Undici attestations", pin.undici?.attestations?.sha256], + ["brace-expansion metadata", pin.braceExpansion?.metadata?.sha256], + ["Node SHASUMS", pin.toolchain?.node?.shasumsSha256], + ["bundled npm package metadata", pin.toolchain?.npm?.packageJsonSha256], + ["patch", pin.patch?.sha256], + ...Object.entries(pin.sourcePreimages ?? {}).map(([path, value]) => [`sourcePreimages.${path}`, value]), + ...Object.entries(pin.sourcePostimages ?? {}).map(([path, value]) => [`sourcePostimages.${path}`, value]), + ...Object.entries(pin.undici?.fixedSourceFiles ?? {}).map(([path, value]) => [`fixedSourceFiles.${path}`, value]), + ]) expectHash(value, 64, label); + + return pin; +} + +export function validatePatch(patch, pin) { + refuse(typeof patch === "string" && patch.length > 0, "patch is empty"); + refuse(digest(patch) === pin.patch.sha256, `patch sha256 does not match ${pin.patch.sha256}`); + const targets = [...patch.matchAll(/^diff --git a\/(.+) b\/(.+)$/gm)].map((match) => { + refuse(match[1] === match[2], `patch renames a target: ${match[1]} -> ${match[2]}`); + return match[1]; + }); + refuse(JSON.stringify(targets) === JSON.stringify(pin.patch.allowedTargets), `patch targets must be exactly ${pin.patch.allowedTargets.join(", ")}`); + refuse(!patch.includes("install-lock"), "patch must not rewrite the registry install-lock"); + + const allowedOld = new Set([ + '\t\t\t"version": "8.9.0",', + '\t\t\t"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",', + '\t\t\t"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",', + '\t\t\t\t"undici": "8.9.0",', + '\t\t"undici": "8.9.0",', + '\t\t\t"version": "5.0.9",', + '\t\t\t"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",', + '\t\t\t"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",', + ]); + const allowedNew = new Set([ + '\t\t\t"version": "8.12.2",', + `\t\t\t"resolved": "${UNDICI_URL}",`, + `\t\t\t"integrity": "${UNDICI_INTEGRITY}",`, + '\t\t\t\t"undici": "8.12.2",', + '\t\t"undici": "8.12.2",', + '\t\t\t"version": "5.0.12",', + `\t\t\t"resolved": "${BRACE_URL}",`, + `\t\t\t"integrity": "${BRACE_INTEGRITY}",`, + ]); + for (const line of patch.split("\n")) { + if (line.startsWith("---") || line.startsWith("+++")) continue; + if (line.startsWith("-")) refuse(allowedOld.has(line.slice(1)), `unapproved patch deletion: ${line}`); + if (line.startsWith("+")) refuse(allowedNew.has(line.slice(1)), `unapproved patch addition: ${line}`); + } + refuse((patch.match(/8\.9\.0/g) ?? []).length === 7, "patch must replace exactly seven 8.9.0 literals"); + refuse((patch.match(/8\.12\.2/g) ?? []).length === 7, "patch must add exactly seven 8.12.2 literals"); + refuse((patch.match(/5\.0\.9/g) ?? []).length === 4, "patch must replace exactly four 5.0.9 literals"); + refuse((patch.match(/5\.0\.12/g) ?? []).length === 4, "patch must add exactly four 5.0.12 literals"); + return { sha256: digest(patch), targets }; +} + +export function validateSourceTree(root, pin) { + validateDigestMap(root, pin.sourcePreimages, "source preimage"); + const packageLock = json(safePath(root, "package-lock.json")); + const codingPackage = json(safePath(root, "packages/coding-agent/package.json")); + const shrinkwrap = json(safePath(root, "packages/coding-agent/npm-shrinkwrap.json")); + const bundler = readFileSync(safePath(root, "scripts/build-coding-agent-bundle.mjs"), "utf8"); + + refuse(codingPackage.name === PI_NAME && codingPackage.version === PI_VERSION, "source coding-agent identity changed"); + refuse(codingPackage.bin?.pi === "dist/bundle/cli.js", "source Pi bin is not the bundled CLI"); + refuse(codingPackage.dependencies?.undici === UNDICI_OLD, `source coding-agent must start at Undici ${UNDICI_OLD}`); + assertUndiciResolution(directUndici(packageLock), UNDICI_OLD, "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", undefined, "source package-lock Undici"); + refuse(codingWorkspace(packageLock)?.dependencies?.undici === UNDICI_OLD, "source workspace lock Undici changed"); + assertUndiciResolution(directUndici(shrinkwrap), UNDICI_OLD, "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", undefined, "source shrinkwrap Undici"); + refuse(shrinkwrapRoot(shrinkwrap)?.dependencies?.undici === UNDICI_OLD, "source shrinkwrap root Undici changed"); + assertUndiciResolution(directBraceExpansion(packageLock), BRACE_OLD, "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", undefined, "source package-lock brace-expansion"); + assertUndiciResolution(directBraceExpansion(shrinkwrap), BRACE_OLD, "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", undefined, "source shrinkwrap brace-expansion"); + refuse(packageLock.packages?.["node_modules/@earendil-works/gondolin/node_modules/undici"]?.version === "6.28.0", "unrelated Gondolin Undici pin changed"); + refuse(/bundle:\s*true/.test(bundler), "upstream coding-agent build no longer bundles dependencies"); + const externalBlock = bundler.match(/allowedExternalPackages\s*=\s*new Set\(\[([\s\S]*?)\]\)/)?.[1] ?? ""; + refuse(externalBlock.length > 0 && !/["']undici(?:\/[^"']*)?["']/.test(externalBlock), "Undici is externalized or the bundler allowlist is unreadable"); + + if (pin.sourcePreimages["packages/coding-agent/install-lock/package-lock.json"]) { + const installLock = json(safePath(root, "packages/coding-agent/install-lock/package-lock.json")); + assertUndiciResolution(directUndici(installLock), UNDICI_OLD, "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", undefined, "published-registry install-lock Undici"); + assertUndiciResolution(directBraceExpansion(installLock), BRACE_OLD, "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", undefined, "published-registry install-lock brace-expansion"); + } + return { piVersion: codingPackage.version, undiciVersion: codingPackage.dependencies.undici, braceExpansionVersion: directBraceExpansion(shrinkwrap).version, bin: codingPackage.bin.pi }; +} + +export function validatePatchedTree(root, pin) { + validateDigestMap(root, pin.sourcePostimages, "source postimage"); + const packageLock = json(safePath(root, "package-lock.json")); + const codingPackage = json(safePath(root, "packages/coding-agent/package.json")); + const shrinkwrap = json(safePath(root, "packages/coding-agent/npm-shrinkwrap.json")); + refuse(codingPackage.name === PI_NAME && codingPackage.version === PI_VERSION, "patched coding-agent identity changed"); + refuse(codingPackage.bin?.pi === "dist/bundle/cli.js", "patched Pi bin is not the bundled CLI"); + refuse(codingPackage.dependencies?.undici === UNDICI_FIXED, `patched coding-agent must use Undici ${UNDICI_FIXED}`); + assertUndiciResolution(directUndici(packageLock), UNDICI_FIXED, UNDICI_URL, UNDICI_INTEGRITY, "patched package-lock Undici"); + refuse(codingWorkspace(packageLock)?.dependencies?.undici === UNDICI_FIXED, "patched workspace lock Undici changed"); + assertUndiciResolution(directUndici(shrinkwrap), UNDICI_FIXED, UNDICI_URL, UNDICI_INTEGRITY, "patched shrinkwrap Undici"); + refuse(shrinkwrapRoot(shrinkwrap)?.dependencies?.undici === UNDICI_FIXED, "patched shrinkwrap root Undici changed"); + assertUndiciResolution(directBraceExpansion(packageLock), BRACE_FIXED, BRACE_URL, BRACE_INTEGRITY, "patched package-lock brace-expansion"); + assertUndiciResolution(directBraceExpansion(shrinkwrap), BRACE_FIXED, BRACE_URL, BRACE_INTEGRITY, "patched shrinkwrap brace-expansion"); + refuse(packageLock.packages?.["node_modules/@earendil-works/gondolin/node_modules/undici"]?.version === "6.28.0", "unrelated Gondolin Undici pin changed"); + const installLock = json(safePath(root, "packages/coding-agent/install-lock/package-lock.json")); + assertUndiciResolution(directUndici(installLock), UNDICI_OLD, "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", undefined, "unchanged published-registry install-lock Undici"); + assertUndiciResolution(directBraceExpansion(installLock), BRACE_OLD, "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", undefined, "unchanged published-registry install-lock brace-expansion"); + return { piVersion: codingPackage.version, undiciVersion: codingPackage.dependencies.undici, braceExpansionVersion: directBraceExpansion(shrinkwrap).version, bin: codingPackage.bin.pi }; +} + +export function assertInstalledUndici(root, pin) { + const manifest = json(safePath(root, "package.json")); + refuse(manifest.name === "undici" && manifest.version === UNDICI_FIXED, `installed Undici must be exactly ${UNDICI_FIXED}`); + for (const [path, expected] of Object.entries(pin.undici.fixedSourceFiles)) { + const actual = fileDigest(safePath(root, path)); + refuse(actual === expected, `installed Undici fixed-source hash mismatch for ${path}`); + } + return { version: manifest.version, sourceFiles: pin.undici.fixedSourceFiles }; +} + +function quoted(contents, start) { + const quote = contents[start]; + let value = ""; + for (let index = start + 1; index < contents.length; index++) { + const character = contents[index]; + if (character === "\\") { + refuse(index + 1 < contents.length, "unterminated escape in bundled JavaScript"); + value += contents[index + 1]; + index++; + continue; + } + if (character === quote) return { next: index + 1, value }; + value += character; + } + throw new Error(`pi rebuild recipe refused: unterminated string at byte ${start} in bundled JavaScript near ${JSON.stringify(contents.slice(Math.max(0, start - 80), start + 160))}`); +} + +function skipTrivia(contents, start) { + let index = start; + while (index < contents.length) { + if (/\s/.test(contents[index])) { + index++; + continue; + } + if (contents.startsWith("//", index)) { + const end = contents.indexOf("\n", index + 2); + index = end < 0 ? contents.length : end + 1; + continue; + } + if (contents.startsWith("/*", index)) { + const end = contents.indexOf("*/", index + 2); + refuse(end >= 0, "unterminated block comment in bundled JavaScript"); + index = end + 2; + continue; + } + break; + } + return index; +} + +function identifierAt(contents, start) { + if (!/[A-Za-z_$]/.test(contents[start] ?? "")) return undefined; + let end = start + 1; + while (/[A-Za-z0-9_$]/.test(contents[end] ?? "")) end++; + return { end, value: contents.slice(start, end) }; +} + +function importClauseSpecifier(contents, start) { + let index = start; + while (index < contents.length) { + index = skipTrivia(contents, index); + if (contents[index] === ";") return undefined; + if (contents[index] === '"' || contents[index] === "'") { + index = quoted(contents, index).next; + continue; + } + const identifier = identifierAt(contents, index); + if (identifier) { + index = identifier.end; + if (identifier.value !== "from") continue; + index = skipTrivia(contents, index); + refuse(contents[index] === '"' || contents[index] === "'", "module from-clause has no literal specifier"); + return quoted(contents, index); + } + index++; + } + return undefined; +} + +function regexLiteralEnd(contents, start) { + let characterClass = false; + for (let index = start + 1; index < contents.length; index++) { + const character = contents[index]; + if (character === "\\") { + index++; + continue; + } + if (character === "[") { + characterClass = true; + continue; + } + if (character === "]") { + characterClass = false; + continue; + } + if (character === "/" && !characterClass) { + index++; + while (/[A-Za-z]/.test(contents[index] ?? "")) index++; + return index; + } + refuse(character !== "\n" && character !== "\r", `unterminated regex literal at byte ${start} in bundled JavaScript`); + } + throw new Error(`pi rebuild recipe refused: unterminated regex literal at byte ${start} in bundled JavaScript`); +} + +function importSpecifiers(contents) { + const found = new Set(); + const regexPrefixKeywords = new Set([ + "await", "case", "delete", "do", "else", "in", "instanceof", "new", "of", "return", "throw", "typeof", "void", "yield", + ]); + let index = 0; + let canStartRegex = true; + while (index < contents.length) { + index = skipTrivia(contents, index); + const character = contents[index]; + if (character === '"' || character === "'" || character === "`") { + index = quoted(contents, index).next; + canStartRegex = false; + continue; + } + if (character === "/") { + if (canStartRegex) { + index = regexLiteralEnd(contents, index); + canStartRegex = false; + } else { + index += contents[index + 1] === "=" ? 2 : 1; + canStartRegex = true; + } + continue; + } + if (/[0-9]/.test(character ?? "")) { + index++; + while (/[A-Za-z0-9_.]/.test(contents[index] ?? "")) index++; + canStartRegex = false; + continue; + } + const identifier = identifierAt(contents, index); + if (!identifier) { + if ((character === "+" || character === "-") && contents[index + 1] === character) { + index += 2; + canStartRegex = false; + continue; + } + canStartRegex = !")]}".includes(character) && character !== "."; + index++; + continue; + } + index = identifier.end; + if (identifier.value !== "import" && identifier.value !== "export") { + canStartRegex = regexPrefixKeywords.has(identifier.value); + continue; + } + index = skipTrivia(contents, index); + if (identifier.value === "import" && contents[index] === ".") { + canStartRegex = false; + continue; + } + if (identifier.value === "import" && contents[index] === "(") { + index = skipTrivia(contents, index + 1); + if (contents[index] === '"' || contents[index] === "'") { + const specifier = quoted(contents, index); + found.add(specifier.value); + index = specifier.next; + } + canStartRegex = false; + continue; + } + if (identifier.value === "import" && (contents[index] === '"' || contents[index] === "'")) { + const specifier = quoted(contents, index); + found.add(specifier.value); + index = specifier.next; + canStartRegex = false; + continue; + } + const specifier = importClauseSpecifier(contents, index); + if (specifier) { + found.add(specifier.value); + index = specifier.next; + } + canStartRegex = false; + } + return found; +} + +function reachableBundle(packageRoot, entry) { + const bundleRoot = realpathSync(safePath(packageRoot, "dist/bundle")); + const entryPath = safePath(packageRoot, entry); + const entryStat = lstatSync(entryPath); + refuse(entryStat.isFile() && !entryStat.isSymbolicLink(), `bundle entry is not a regular non-symlink file: ${entry}`); + const queue = [realpathSync(entryPath)]; + const files = new Map(); + const external = new Set(); + while (queue.length > 0) { + const file = queue.shift(); + refuse(file === bundleRoot || file.startsWith(`${bundleRoot}${sep}`), `bundle import escapes dist/bundle: ${file}`); + if (files.has(file)) continue; + const contents = readFileSync(file, "utf8"); + files.set(file, contents); + let specifiers; + try { + specifiers = importSpecifiers(contents); + } catch (error) { + throw new Error(`${error.message}; bundle file ${relative(packageRoot, file).split(sep).join("/")}`); + } + for (const specifier of specifiers) { + if (!specifier.startsWith(".")) { + external.add(specifier); + continue; + } + const unresolved = resolve(dirname(file), specifier); + const targetStat = lstatSync(unresolved); + refuse(targetStat.isFile() && !targetStat.isSymbolicLink(), `bundle import is not a regular non-symlink file: ${specifier}`); + const target = realpathSync(unresolved); + refuse(target.startsWith(`${bundleRoot}${sep}`), `relative bundle import escapes dist/bundle: ${specifier}`); + queue.push(target); + } + } + return { files, external }; +} + +export function inspectBundleClosure(packageRoot, entry = "dist/bundle/cli.js") { + const closure = reachableBundle(packageRoot, entry); + return { + reachableFiles: [...closure.files.keys()] + .map((path) => relative(packageRoot, path).split(sep).join("/")) + .sort(), + externalImports: [...closure.external].sort(), + }; +} + +export function assertBundleConsumption(packageRoot, pin, options = {}) { + const manifest = json(safePath(packageRoot, "package.json")); + const shrinkwrap = json(safePath(packageRoot, "npm-shrinkwrap.json")); + refuse(manifest.name === PI_NAME && manifest.version === PI_VERSION, `artifact identity must remain ${PI_NAME}@${PI_VERSION}`); + refuse(manifest.bin?.pi === "dist/bundle/cli.js", "artifact Pi bin does not consume dist/bundle/cli.js"); + assertUndiciResolution(directUndici(shrinkwrap), UNDICI_FIXED, UNDICI_URL, UNDICI_INTEGRITY, "artifact shrinkwrap Undici"); + refuse(shrinkwrapRoot(shrinkwrap)?.dependencies?.undici === UNDICI_FIXED, "artifact shrinkwrap root does not declare Undici 8.12.2"); + assertUndiciResolution(directBraceExpansion(shrinkwrap), BRACE_FIXED, BRACE_URL, BRACE_INTEGRITY, "artifact shrinkwrap brace-expansion"); + const installedUndici = options.installedUndiciRoot ? assertInstalledUndici(options.installedUndiciRoot, pin) : undefined; + + const closure = reachableBundle(packageRoot, manifest.bin.pi); + refuse(![...closure.external].some((specifier) => specifier === "undici" || specifier.startsWith("undici/")), "bundle leaves Undici external"); + const joined = [...closure.files.values()].join("\n"); + const fixedContentEncoding = /controller\.abort\(new Error\(`too many content-encodings in response:/.test(joined); + const vulnerableContentEncoding = /reject\(new Error\(`too many content-encodings in response:/.test(joined); + // This is a conservative signature check for the direct assertion forms in + // Undici's affected source and bundles, not a proof of arbitrary JS semantics. + const vulnerableTlsAssertion = /\bassert\s*\(\s*[^);\n]*?\.code\s*!==\s*(["'])ERR_TLS_CERT_ALTNAME_INVALID\1/.test(joined); + refuse(fixedContentEncoding, "fixed content-encoding abort signature is absent from the reachable CLI bundle"); + refuse(!vulnerableContentEncoding, "vulnerable content-encoding reject signature remains in the reachable CLI bundle"); + refuse(!vulnerableTlsAssertion, "vulnerable TLS assertion signature remains in the reachable CLI bundle"); + + const reachableFiles = [...closure.files.keys()] + .map((path) => relative(packageRoot, path).split(sep).join("/")) + .sort(); + return { + piVersion: manifest.version, + undiciVersion: directUndici(shrinkwrap).version, + braceExpansionVersion: directBraceExpansion(shrinkwrap).version, + entry: manifest.bin.pi, + reachableFiles, + externalImports: [...closure.external].sort(), + fixedSignatures: { + contentEncodingAbort: true, + vulnerableContentEncodingRejectAbsent: true, + knownDirectVulnerableTlsAssertionSignaturesAbsent: true, + }, + ...(installedUndici ? { installedUndici } : {}), + }; +} + +export const constants = Object.freeze({ + piName: PI_NAME, + piVersion: PI_VERSION, + piCommit: PI_COMMIT, + undiciVersion: UNDICI_FIXED, + undiciUrl: UNDICI_URL, + undiciIntegrity: UNDICI_INTEGRITY, + braceExpansionVersion: BRACE_FIXED, + braceExpansionUrl: BRACE_URL, + braceExpansionIntegrity: BRACE_INTEGRITY, +}); diff --git a/packages/pi-rebuild/tests/recipe.test.mjs b/packages/pi-rebuild/tests/recipe.test.mjs new file mode 100644 index 000000000..111b2a095 --- /dev/null +++ b/packages/pi-rebuild/tests/recipe.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { + assertBundleConsumption, + validatePatch, + validatePin, + validateSourceTree, +} from "../src/recipe.mjs"; +import { writeReceipts } from "../src/receipts.mjs"; + +const PACKAGE_ROOT = resolve(import.meta.dirname, ".."); +const PIN = JSON.parse(readFileSync(join(PACKAGE_ROOT, "PIN.json"), "utf8")); +const PATCH = readFileSync(join(PACKAGE_ROOT, PIN.patch.path), "utf8"); +const TEST_SCRATCH = process.env.MOSAIC_PI_REBUILD_TEST_TMP; + +function scratch(t) { + assert.ok(TEST_SCRATCH, "MOSAIC_PI_REBUILD_TEST_TMP must name the owned mktemp scratch directory"); + const directory = mkdtempSync(join(TEST_SCRATCH, "fixture-")); + t.after(() => rmSync(directory, { force: true, recursive: true })); + return directory; +} + +function sha256(text) { + return createHash("sha256").update(text).digest("hex"); +} + +function write(directory, path, contents) { + const target = join(directory, path); + mkdirSync(resolve(target, ".."), { recursive: true }); + writeFileSync(target, contents); +} + +test("the exact source, toolchain, Undici and three-file patch pins fail closed", () => { + assert.doesNotThrow(() => validatePin(PIN)); + assert.equal(PIN.braceExpansion.version, "5.0.12", "full A5 tree requirement"); + assert.doesNotThrow(() => validatePatch(PATCH, PIN)); + + for (const mutate of [ + (pin) => { pin.pi.version = "0.85.2"; }, + (pin) => { pin.pi.commit = "0".repeat(40); }, + (pin) => { pin.undici.version = "8.12.3"; }, + (pin) => { pin.undici.tarball.sha256 = "0".repeat(64); }, + (pin) => { pin.braceExpansion.version = "5.0.13"; }, + (pin) => { pin.toolchain.node.version = "22.23.3"; }, + (pin) => { pin.pi.source.url = "https://user@example.invalid/pi.tar.gz"; }, + ]) { + const changed = structuredClone(PIN); + mutate(changed); + assert.throws(() => validatePin(changed)); + } + + assert.throws( + () => validatePatch(`${PATCH}\ndiff --git a/package.json b/package.json\n`, PIN), + /sha256|package\.json|allowed|target/i, + ); + assert.throws( + () => validatePatch(PATCH.replace("packages/coding-agent/npm-shrinkwrap.json", "packages/coding-agent/install-lock/package-lock.json"), PIN), + /sha256|install-lock|allowed|target/i, + ); +}); + +test("source validation binds preimage bytes and the existing bundled consumer graph", (t) => { + const root = scratch(t); + const files = { + "package-lock.json": `${JSON.stringify({ packages: { + "node_modules/brace-expansion": { + version: "5.0.9", + resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + }, + "node_modules/undici": { + version: "8.9.0", + resolved: "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + integrity: "old-integrity", + }, + "packages/coding-agent": { dependencies: { undici: "8.9.0" } }, + "node_modules/@earendil-works/gondolin/node_modules/undici": { version: "6.28.0" }, + } }, null, "\t")}\n`, + "packages/coding-agent/package.json": `${JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + version: "0.85.1", + bin: { pi: "dist/bundle/cli.js" }, + dependencies: { undici: "8.9.0" }, + }, null, "\t")}\n`, + "packages/coding-agent/npm-shrinkwrap.json": `${JSON.stringify({ packages: { + "": { version: "0.85.1", dependencies: { undici: "8.9.0" } }, + "node_modules/brace-expansion": { version: "5.0.9", resolved: "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", integrity: "old-brace-integrity" }, + "node_modules/undici": { version: "8.9.0", resolved: "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", integrity: "old-integrity" }, + } }, null, "\t")}\n`, + "scripts/build-coding-agent-bundle.mjs": "const allowedExternalPackages = new Set(['jiti']); const options = { bundle: true };\n", + }; + for (const [path, contents] of Object.entries(files)) write(root, path, contents); + const preimages = Object.fromEntries(Object.entries(files).map(([path, contents]) => [path, sha256(contents)])); + + assert.doesNotThrow(() => validateSourceTree(root, { ...PIN, sourcePreimages: preimages })); + write(root, "packages/coding-agent/package.json", files["packages/coding-agent/package.json"].replace("8.9.0", "8.12.2")); + assert.throws(() => validateSourceTree(root, { ...PIN, sourcePreimages: preimages }), /preimage|sha256/i); +}); + +test("bundle assertion fixture requires the fixed signatures in the reachable CLI closure", (t) => { + const root = scratch(t); + const packageRoot = join(root, "package"); + write(packageRoot, "package.json", `${JSON.stringify({ + name: "@earendil-works/pi-coding-agent", + version: "0.85.1", + bin: { pi: "dist/bundle/cli.js" }, + }, null, "\t")}\n`); + write(packageRoot, "npm-shrinkwrap.json", `${JSON.stringify({ packages: { + "": { version: "0.85.1", dependencies: { undici: "8.12.2" } }, + "node_modules/brace-expansion": { + version: "5.0.12", + resolved: PIN.braceExpansion.tarball.url, + integrity: PIN.braceExpansion.tarball.integrity, + }, + "node_modules/undici": { + version: "8.12.2", + resolved: PIN.undici.tarball.url, + integrity: PIN.undici.tarball.integrity, + }, + } }, null, "\t")}\n`); + write(packageRoot, "dist/bundle/cli.js", 'import "./chunks/fixed.js";\n'); + const fixedBundle = [ + "const documentation=\"pass the fetch implementation from the same undici package: import { fetch, ProxyAgent } from 'undici';\";", + "const syntaxPattern=/from[\"']undici/;", + "function fixed(controller,codings,maxContentEncodings){", + "if(codings.length>maxContentEncodings){controller.abort(new Error(`too many content-encodings in response: ${codings.length}, maximum allowed is ${maxContentEncodings}`));return}", + "}", + "function handled(err2){return err2.code===\"ERR_TLS_CERT_ALTNAME_INVALID\"}", + ].join(""); + write(packageRoot, "dist/bundle/chunks/fixed.js", fixedBundle); + + const evidence = assertBundleConsumption(packageRoot, PIN); + assert.deepEqual(evidence.reachableFiles, ["dist/bundle/chunks/fixed.js", "dist/bundle/cli.js"]); + assert.ok(!evidence.externalImports.includes("undici"), "documentation text is not an import"); + assert.equal(evidence.undiciVersion, "8.12.2"); + const artifact = join(root, "fixture-artifact.tgz"); + writeFileSync(artifact, "fixture only; not a Pi artifact\n"); + for (const outputDirectory of [join(root, "receipts-a"), join(root, "receipts-b")]) { + writeReceipts({ + artifactPath: artifact, + packageRoot, + outputDirectory, + pin: PIN, + toolVersions: { node: "v22.23.4", npm: "10.9.9" }, + }); + } + assert.deepEqual(readdirSync(join(root, "receipts-a")).sort(), readdirSync(join(root, "receipts-b")).sort()); + for (const file of readdirSync(join(root, "receipts-a"))) { + assert.deepEqual(readFileSync(join(root, "receipts-a", file)), readFileSync(join(root, "receipts-b", file)), file); + } + + // Keep the fixed-abort positive present so only the TLS guard can refuse. + for (const assertion of [ + 'assert(err.code!=="ERR_TLS_CERT_ALTNAME_INVALID")', + 'assert(err.code !== "ERR_TLS_CERT_ALTNAME_INVALID")', + "assert (err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')", + ]) { + write(packageRoot, "dist/bundle/chunks/fixed.js", `${fixedBundle};${assertion};`); + assert.throws(() => assertBundleConsumption(packageRoot, PIN), /vulnerable TLS assertion signature/); + } + + write(packageRoot, "dist/bundle/chunks/fixed.js", `import"undici";${fixedBundle}`); + assert.throws(() => assertBundleConsumption(packageRoot, PIN), /leaves Undici external/i); + write(packageRoot, "dist/bundle/chunks/fixed.js", "function vulnerable(reject,err2){reject(new Error(`too many content-encodings in response:`));assert(err2.code!==\"ERR_TLS_CERT_ALTNAME_INVALID\")}\n"); + assert.throws(() => assertBundleConsumption(packageRoot, PIN), /fixed content-encoding|TLS assertion|signature/i); +});