diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef5b18dac..a40a894cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: sudo ln -s $(which fdfind) /usr/local/bin/fd - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Build run: npm run build diff --git a/.github/workflows/npm-audit.yml b/.github/workflows/npm-audit.yml new file mode 100644 index 000000000..021b81ca9 --- /dev/null +++ b/.github/workflows/npm-audit.yml @@ -0,0 +1,31 @@ +name: npm audit + +on: + schedule: + - cron: '37 7 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Audit production vulnerabilities + run: npm audit --omit=dev --audit-level=moderate + + - name: Verify registry signatures + run: npm audit signatures --omit=dev diff --git a/.husky/pre-commit b/.husky/pre-commit index 27d18fbc8..2b21ec961 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,6 +3,11 @@ # Get list of staged files before running check STAGED_FILES=$(git diff --cached --name-only) +node scripts/check-lockfile-commit.mjs +if [ $? -ne 0 ]; then + exit 1 +fi + # Run the check script (formatting, linting, and type checking) echo "Running formatting, linting, and type checking..." npm run check diff --git a/.npmrc b/.npmrc index d9b69800f..72acb9ade 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ save-exact=true -min-release-age=14 +min-release-age=2 diff --git a/README.md b/README.md index 6bdda5475..d7e73cf08 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,20 @@ npm run check # Lint, format, and type check ./pi-test.sh # Run pi from sources (can be run from any directory) ``` +## Supply-chain hardening + +We treat npm dependency changes as reviewed code changes. + +- Direct external dependencies are pinned to exact versions. Internal workspace packages remain version-ranged. +- `.npmrc` sets `save-exact=true` and `min-release-age=2` to avoid same-day dependency releases during npm resolution. +- `package-lock.json` is the dependency ground truth. Pre-commit blocks accidental lockfile commits unless `PI_ALLOW_LOCKFILE_CHANGE=1` is set. +- `npm run check` verifies pinned direct deps, native TypeScript import compatibility, and the generated coding-agent shrinkwrap. +- The published CLI package includes `packages/coding-agent/npm-shrinkwrap.json`, generated from the root lockfile, to pin transitive deps for npm users. +- Release smoke tests use `npm run release:local` to build, pack, and install outside the repo before publishing. +- Local release installs, documented npm installs, and `pi update --self` use `--ignore-scripts` where supported. +- CI installs with `npm ci --ignore-scripts`, and a scheduled GitHub workflow runs `npm audit --omit=dev` plus `npm audit signatures --omit=dev`. +- Shrinkwrap generation has an explicit allowlist for dependency lifecycle scripts; new lifecycle-script deps fail checks until reviewed. + ## License MIT diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index 1c1bf0761..4b94de970 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -57,7 +57,7 @@ if [[ -n "$PLATFORM" ]]; then fi echo "==> Installing dependencies..." -npm ci +npm ci --ignore-scripts if [[ "$SKIP_DEPS" == "false" ]]; then echo "==> Installing cross-platform native bindings..." diff --git a/scripts/check-lockfile-commit.mjs b/scripts/check-lockfile-commit.mjs new file mode 100644 index 000000000..abf789470 --- /dev/null +++ b/scripts/check-lockfile-commit.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; + +const allowValue = process.env.PI_ALLOW_LOCKFILE_CHANGE; +const allowed = allowValue === "1" || allowValue === "true" || allowValue === "yes"; + +function git(args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +function readJsonFromGit(ref) { + try { + return JSON.parse(git(["show", ref])); + } catch { + return undefined; + } +} + +function packageNameFromLockPath(lockPath) { + const marker = "node_modules/"; + const index = lockPath.lastIndexOf(marker); + if (index === -1) return lockPath || ""; + const parts = lockPath.slice(index + marker.length).split("/"); + return parts[0]?.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0]; +} + +function packageLabel(lockPath, entry) { + const name = entry?.name ?? packageNameFromLockPath(lockPath); + return entry?.version ? `${name}@${entry.version}` : name; +} + +function summarizeLockfileChange() { + const before = readJsonFromGit("HEAD:package-lock.json"); + const after = readJsonFromGit(":package-lock.json"); + if (!before?.packages || !after?.packages) return []; + + const changes = []; + const paths = new Set([...Object.keys(before.packages), ...Object.keys(after.packages)]); + for (const lockPath of [...paths].sort()) { + if (!lockPath.includes("node_modules/")) continue; + const oldEntry = before.packages[lockPath]; + const newEntry = after.packages[lockPath]; + if (!oldEntry && newEntry) { + changes.push(`added ${packageLabel(lockPath, newEntry)}`); + } else if (oldEntry && !newEntry) { + changes.push(`removed ${packageLabel(lockPath, oldEntry)}`); + } else if (oldEntry?.version !== newEntry?.version) { + changes.push( + `changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? ""} -> ${newEntry?.version ?? ""}`, + ); + } + } + return changes; +} + +const stagedFiles = git(["diff", "--cached", "--name-only"]) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + +if (!stagedFiles.includes("package-lock.json")) { + process.exit(0); +} + +if (allowed) { + console.error("package-lock.json is staged; PI_ALLOW_LOCKFILE_CHANGE is set, allowing commit."); + process.exit(0); +} + +console.error("package-lock.json is staged."); +console.error(""); +console.error("Review lockfile changes before committing:"); +console.error(" - confirm every new/updated package is intentional"); +console.error(" - confirm npm age gates were active for resolution"); +console.error(" - review any new lifecycle scripts in the dependency tree"); +console.error(" - regenerate/check coding-agent shrinkwrap if release deps changed"); + +const changes = summarizeLockfileChange(); +if (changes.length > 0) { + console.error(""); + console.error("Detected package version changes:"); + for (const change of changes.slice(0, 40)) { + console.error(` - ${change}`); + } + if (changes.length > 40) { + console.error(` ... ${changes.length - 40} more`); + } +} + +console.error(""); +console.error("If this lockfile change is intentional, commit with:"); +console.error(" PI_ALLOW_LOCKFILE_CHANGE=1 git commit ..."); +process.exit(1); diff --git a/scripts/generate-coding-agent-shrinkwrap.mjs b/scripts/generate-coding-agent-shrinkwrap.mjs index 563b05288..a122f1835 100644 --- a/scripts/generate-coding-agent-shrinkwrap.mjs +++ b/scripts/generate-coding-agent-shrinkwrap.mjs @@ -10,6 +10,11 @@ const codingAgentDir = join(repoRoot, "packages/coding-agent"); const rootLockfilePath = join(repoRoot, "package-lock.json"); const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json"); const internalPackagePrefix = "@earendil-works/pi-"; +const allowedInstallScriptPackages = new Map([ + ["@google/genai@1.52.0", "preinstall is a no-op in the published package"], + ["koffi@2.16.2", "optional native package ships prebuilt modules used without install scripts"], + ["protobufjs@7.5.9", "postinstall only warns about protobufjs version scheme mismatches"], +]); const args = new Set(process.argv.slice(2)); const checkOnly = args.has("--check"); @@ -221,6 +226,7 @@ function validateShrinkwrap(shrinkwrap, internalNames) { const errors = []; const includedPaths = new Set(Object.keys(shrinkwrap.packages)); const includedPackageNames = new Set(); + const seenAllowedInstallScriptPackages = new Set(); for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) { const packageName = packageNameFromLockPath(lockPath); @@ -233,6 +239,26 @@ function validateShrinkwrap(shrinkwrap, internalNames) { if (typeof entry.resolved === "string" && /^(file:|link:|workspace:|\.\.?\/|\/)/.test(entry.resolved)) { errors.push(`${lockPath} has a local resolved value: ${entry.resolved}`); } + if (entry.hasInstallScript) { + if (!packageName || !entry.version) { + errors.push(`${lockPath || "root"} has install scripts but no package name/version`); + } else { + const packageId = `${packageName}@${entry.version}`; + if (allowedInstallScriptPackages.has(packageId)) { + seenAllowedInstallScriptPackages.add(packageId); + } else { + errors.push( + `${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`, + ); + } + } + } + } + + for (const packageId of allowedInstallScriptPackages.keys()) { + if (!seenAllowedInstallScriptPackages.has(packageId)) { + errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`); + } } for (const name of internalNames) { diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 31e07901c..d5b4b4165 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -167,7 +167,7 @@ if (!options.skipInstall) { `${JSON.stringify({ private: true, dependencies }, undefined, "\t")}\n`, ); - run("npm", ["install", "--omit=dev"], { cwd: installDirectory }); + run("npm", ["install", "--omit=dev", "--ignore-scripts"], { cwd: installDirectory }); mkdirSync(binDirectory, { recursive: true }); symlinkSync(join(installDirectory, "node_modules", ".bin", "pi"), join(binDirectory, "pi")); }