From 5e5b5fdb50a28fe2c3cc9c8db5a81ea5c173abf4 Mon Sep 17 00:00:00 2001 From: m1ngshum <140998506+m1ngshum@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:28:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20mcpm=20verify=20+=20GitHub=20Action?= =?UTF-8?q?=20=E2=80=94=20repo-only=20lockfile=20integrity=20gate=20(D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of the existing commands run on a hosted CI runner (up hard-fails on zero detected clients; sync --check/audit are vacuously green there), so verify is a genuinely new client-free path — not a thin wrapper. - Extract classifyIntegrity + a new pure frozenVerdict from up.ts into src/stack/frozen-verify.ts. up --frozen now consumes them; its output and block matrix are byte-identical (13 frozen tests pass unchanged). - mcpm verify [--json]: loads mcpm-lock.yaml and runs that same fail-closed integrity pass with the same BLOCK semantics + exit codes as up --frozen (exit 1 on drift / unverifiable / format mismatch / mixed missing baseline; benign refuse on a lock-wide no-baseline; pypi/oci/url reported unenforceable; exit 1 on no-lock — and verify NEVER auto-locks). No client detection, no ~/.mcpm, no writes. --json emits a structured VerifyModel. - Composite Action .github/actions/mcpm-verify: wraps verify, writes a job step summary from --json, static badge + pre-commit snippet in its README. - completions (bash/zsh/fish) + the invariant test updated for the new command. ONE verb: B3 later extends mcpm verify with Sigstore provenance, never forks it. v1 scope = npm dist.integrity; stack-vs-lock staleness deferred. 1940 tests green; verified e2e on the built CLI. --- .github/actions/mcpm-verify/README.md | 70 +++++++++ .github/actions/mcpm-verify/action.yml | 53 +++++++ CHANGELOG.md | 18 +++ CLAUDE.md | 3 +- README.md | 26 ++++ docs/CONTRACTS.md | 3 +- docs/ROADMAP-ADOPTION.md | 12 +- src/__tests__/commands/verify.test.ts | 159 ++++++++++++++++++++ src/commands/completions.ts | 4 +- src/commands/index.ts | 4 + src/commands/up.ts | 174 ++++++---------------- src/commands/verify.ts | 195 +++++++++++++++++++++++++ src/stack/frozen-verify.ts | 173 ++++++++++++++++++++++ 13 files changed, 760 insertions(+), 134 deletions(-) create mode 100644 .github/actions/mcpm-verify/README.md create mode 100644 .github/actions/mcpm-verify/action.yml create mode 100644 src/__tests__/commands/verify.test.ts create mode 100644 src/commands/verify.ts create mode 100644 src/stack/frozen-verify.ts diff --git a/.github/actions/mcpm-verify/README.md b/.github/actions/mcpm-verify/README.md new file mode 100644 index 0000000..baa84a9 --- /dev/null +++ b/.github/actions/mcpm-verify/README.md @@ -0,0 +1,70 @@ +# `mcpm verify` GitHub Action + +A fail-closed CI gate that verifies your committed `mcpm-lock.yaml` against npm's +**published** `dist.integrity` record. It runs `mcpm verify` — repo-only, no AI +clients required — so it works on a hosted runner where `mcpm up` cannot. + +The step fails (non-zero) on integrity **drift**, an **unverifiable** record, an +integrity **format mismatch**, or a **suspicious missing baseline**, and writes a +job **step summary** from the `--json` model. + +> Honesty boundary: a failure means npm's *published record* diverged from (or +> can't be matched against) your lock — **not** that mcpm caught malicious bytes. +> npx/uvx fetch the artifact independently at server launch. + +## Usage + +Pin the action to a release SHA (or tag): + +```yaml +name: mcpm +on: [push, pull_request] +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: getmcpm/cli/.github/actions/mcpm-verify@v0.19.0 + # with: + # version: latest # @getmcpm/cli version/dist-tag to run + # working-directory: . # dir containing mcpm.yaml / mcpm-lock.yaml +``` + +Equivalent one-liner (no action): + +```yaml + - run: npx --yes @getmcpm/cli verify +``` + +Pre-commit hook (same verb): + +```yaml +# .pre-commit-config.yaml +- repo: local + hooks: + - id: mcpm-verify + name: mcpm verify + entry: npx --yes @getmcpm/cli verify + language: system + pass_filenames: false +``` + +## Badge + +Once the gate is in your CI, advertise it with a static badge: + +```markdown +![mcpm verified](https://img.shields.io/badge/mcpm-verified-brightgreen) +``` + +## Inputs + +| Input | Default | Description | +|---|---|---| +| `version` | `latest` | Version or dist-tag of `@getmcpm/cli` to run. | +| `working-directory` | `.` | Directory containing `mcpm.yaml` / `mcpm-lock.yaml`. | + +## Exit codes + +`0` verified · `1` block (integrity drift / unverifiable / format mismatch / +missing baseline) or no lock file found. See `docs/CONTRACTS.md`. diff --git a/.github/actions/mcpm-verify/action.yml b/.github/actions/mcpm-verify/action.yml new file mode 100644 index 0000000..6dbb2ad --- /dev/null +++ b/.github/actions/mcpm-verify/action.yml @@ -0,0 +1,53 @@ +name: 'mcpm verify' +description: "Fail-closed lockfile integrity gate — verify mcpm-lock.yaml against npm's published record." +branding: + icon: 'shield' + color: 'green' + +inputs: + version: + description: 'Version or dist-tag of @getmcpm/cli to run.' + required: false + default: 'latest' + working-directory: + description: 'Directory containing mcpm.yaml / mcpm-lock.yaml.' + required: false + default: '.' + +runs: + using: 'composite' + steps: + - name: Run mcpm verify + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + MCPM_CLI_VERSION: ${{ inputs.version }} + run: | + # Capture the JSON model AND the exit code. `verify` exits non-zero on an + # integrity block, and that becomes this step's (and the job's) exit code. + set +e + out="$(npx --yes "@getmcpm/cli@${MCPM_CLI_VERSION}" verify --json)" + code=$? + set -e + echo "$out" + + # Render a job step summary from --json (node is preinstalled on GH runners). + printf '%s' "$out" | node -e ' + let d; try { d = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch { process.exit(0); } + const fs = require("fs"); const sum = process.env.GITHUB_STEP_SUMMARY; + if (!sum) process.exit(0); + const L = ["### mcpm verify"]; + if (d.error) L.push("", "✗ " + d.error); + else if (d.noBaselines) L.push("", "✗ lock has no integrity baselines — run `mcpm lock` online once."); + else if (d.ok) L.push("", "✓ " + d.verified + " npm server(s) verified against npm’s published record."); + else { + L.push("", "✗ verification failed — " + (d.blocked || []).length + " server(s):", ""); + for (const b of d.blocked || []) L.push("- `" + (b.identifier || b.name) + "` — " + b.reason); + } + if ((d.unenforceable || []).length) + L.push("", "_" + d.unenforceable.length + " server(s) (pypi/oci/url) have no baseline mechanism — not enforced._"); + L.push("", "_mcpm checks the registry’s published record, not the code your agent runs._"); + fs.appendFileSync(sum, L.join("\n") + "\n"); + ' || true + + exit $code diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5e65a..0896ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file. ### Added +- **`mcpm verify` + an official GitHub Action (D2)** — a repo-only, **client-free** + integrity gate for CI. `mcpm verify` loads `mcpm-lock.yaml` and runs the same + fail-closed integrity pass as `mcpm up --frozen` — BLOCK (exit 1) on integrity + drift, an unverifiable record, a format mismatch, or a suspicious missing baseline + — but, unlike `up`, it needs **no AI clients installed**, so it runs on a hosted + runner (where `up` hard-fails on zero detected clients). `--json` emits a + structured model; a composite Action at `.github/actions/mcpm-verify` wraps it with + a job step summary. Pre-commit rides the same verb. Deliberately **one verb**: + provenance (Sigstore) will *extend* `mcpm verify` later, never fork it. v1 scope is + npm `dist.integrity`; pypi/oci/url are reported as unenforceable, and stack-vs-lock + staleness is a deferred follow-up. - **`mcpm doctor --json` and `--report`** — `doctor`'s checks are now built into a structured `DoctorModel` (clients with server + guarded counts, runtimes, advisory cross-client drift, typed issues, `ok`). `--json` emits that model (shape UNSTABLE); @@ -27,6 +38,13 @@ All notable changes to this project will be documented in this file. `url`, the same URL-transport caveat that already applies to non-Cursor clients. Zero new deps. +### Internal + +- **Shared frozen-verify extraction** — `classifyIntegrity` + a new pure + `frozenVerdict` moved from `up.ts` into `src/stack/frozen-verify.ts`, consumed by + both `up --frozen` and the new `mcpm verify`. `up`'s output and block matrix are + byte-identical (its 13 frozen tests pass unchanged). + ### Fixed - **`mcpm_doctor` MCP tool now reports real issues** — it previously returned a diff --git a/CLAUDE.md b/CLAUDE.md index f4ba395..62dfb33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,7 +133,7 @@ Build the **open-source, community-owned npm+npm_audit** for MCP: - **Commands**: `mcpm search`, `mcpm install`, `mcpm list`, `mcpm remove`, `mcpm info`, `mcpm audit`, `mcpm update`, `mcpm outdated`, `mcpm doctor`, `mcpm init`, `mcpm import`, `mcpm serve`, `mcpm disable`, `mcpm enable`, `mcpm alias`, `mcpm completions`, - `mcpm export`, `mcpm lock`, `mcpm up`, `mcpm diff`, `mcpm publish`, `mcpm guard`, + `mcpm export`, `mcpm lock`, `mcpm up`, `mcpm verify`, `mcpm diff`, `mcpm publish`, `mcpm guard`, `mcpm guard doctor-confine`, `mcpm secrets`, `mcpm why` (`mcpm guard enable --confine` opt-in wraps unwrapped stdio servers in an OS sandbox — macOS-first) @@ -500,6 +500,7 @@ the same entry shape). | 2026-07-03 | v0.17.0 released (Wave-0 adoption credibility floor) + started Wave 1 with the Claude Code adapter (D1) | Following the new **docs/ROADMAP-ADOPTION.md**: v0.17.0 shipped the four Wave-0 items (E3 supply-chain evidence pack, B1a macOS CI leg, E11 `docs/CONTRACTS.md`, E9a registry-delisting gate). Then D1 (#117) added **Claude Code** as a 5th first-class client (`~/.claude.json`, user-global `mcpServers`) — a rootKey-only `BaseAdapter` subclass; **user-global scope only**, per-project `projects[].mcpServers` deliberately deferred. D1 is on `main`, ships next tag. | | 2026-07-03 | Publish workflow: attach the release SBOM at `gh release create` time, never via a follow-up `gh release upload` | The repo has GitHub **immutable releases** enabled: uploading an asset to an already-created release returns HTTP 422. v0.17.0's publish succeeded on npm but failed the release-asset step (SBOM), so its GitHub release is asset-less and can't be amended. Fix (#116): pass `mcpm.cdx.json` as a positional asset to `gh release create` so it's sealed atomically. Correct from v0.18.0 on. GOTCHA for any future release-pipeline work. | | 2026-07-03 | Wave-1 D4a: Gemini CLI as a 6th first-class client (`~/.gemini/settings.json`, user-global `mcpServers`) | Structural clone of D1 — another rootKey-only `BaseAdapter` subclass; verified the format against Google's gemini-cli docs (top-level `mcpServers`, home-relative on all platforms). Detector auto-enumerates it, so every client-iterating command works day one. **User-global scope only**, per-project `.gemini/settings.json` deliberately out of scope (same as D1). Caveat: Gemini reads `url`=SSE / `httpUrl`=HTTP; mcpm writes `url`, the existing non-Cursor URL-transport caveat. Merged to `main` (#120), ships next tag. | +| 2026-07-03 | Wave-1 D2: `mcpm verify` (repo-only, client-free CI gate) + a composite GitHub Action; `classifyIntegrity`/`frozenVerdict` extracted to `src/stack/frozen-verify.ts` | The critique premise held: NO existing command runs on a hosted runner (`up` hard-fails at Step 3 on zero detected clients; `sync --check`/`audit` are vacuously green). So `mcpm verify` runs the SAME fail-closed integrity pass as `up --frozen` — extracted `classifyIntegrity` + a NEW pure `frozenVerdict` (structured pass/block decision) into a shared module both consume — but client-free (no detection, no `~/.mcpm`, no writes). Same BLOCK semantics + exit codes (1 on drift/unverifiable/format-mismatch/mixed-missing-baseline; benign refuse on lock-wide no-baseline; pypi/oci/url = unenforceable notice; exit 1 on no-lock, and verify NEVER auto-locks). `up`'s output/block-matrix is byte-identical (13 frozen tests unchanged — the refactor's safety net). `--json` emits a VerifyModel (`schemaVersion:1`). Composite Action `.github/actions/mcpm-verify` (step summary from `--json`, static shields badge, pre-commit snippet). **ONE verb**: B3 later EXTENDS `mcpm verify` with Sigstore provenance, never forks it. v1 = npm `dist.integrity` only; stack-vs-lock staleness deferred. On `main`, ships next tag. | | 2026-07-03 | Wave-1 D7: `doctor --json` / `--report` via a pure `DoctorModel` builder + redacted report; fixes the `mcpm_doctor` `issues: []` bug | `doctorHandler` split into `buildDoctorModel` (pure, structured — `schemaVersion:1`, clients w/ server+guarded counts, runtimes, advisory drift, typed issues, `ok`) → renderers. `--json` emits the model; the MCP `handleDoctor` now REUSES the model (was returning hardcoded `issues: []` + detected-clients-only). `--report` = a redacted paste-for-bug-reports snapshot: OS/arch, mcpm+node versions, per-client server **counts**, runtime availability, confine + secret-store backend, issue **counts** — deliberately **no server names/args** (issue messages embed names → reduced to counts; the tested security invariant). New `.github/ISSUE_TEMPLATE/bug.yml` requires a pasted report (telemetry-free friction channel). D7 is the FIRST of the four structured-output mappers (D3/D7/E2/E6) to land: the shared "one model" is the `schemaVersion` + JSON convention, NOT a monolithic type — doctor issues stayed a doctor-specific typed list, deliberately NOT forced into the audit `Finding` shape (different domain). doctor `--json`/`--report` shapes UNSTABLE (added to CONTRACTS). On `main`, ships next tag. | --- diff --git a/README.md b/README.md index 7c34df3..93683f1 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ Without an external scanner installed, the maximum possible score is 80/100. The | `mcpm export` | Export installed servers as an mcpm.yaml stack file | | `mcpm lock` | Resolve versions and create mcpm-lock.yaml with trust snapshots | | `mcpm up` | Install all servers from mcpm.yaml with trust verification | +| `mcpm verify` | Repo-only CI gate: verify lockfile integrity vs npm's published record (`--json`) | | `mcpm diff` | Compare installed servers against mcpm.yaml and lock file | | `mcpm completions ` | Generate shell completion scripts (bash, zsh, fish) | | `mcpm why ` | Explain a server's trust score (breakdown of all components) | @@ -209,6 +210,31 @@ Without an external scanner installed, the maximum possible score is 80/100. The Run `mcpm --help` for options and flags. +## CI: verify your lockfile + +`mcpm verify` is a repo-only, **client-free** gate: it checks your committed +`mcpm-lock.yaml` against npm's **published** `dist.integrity` record and exits +non-zero on integrity drift, an unverifiable record, a format mismatch, or a +suspicious missing baseline. Because it needs no AI clients installed, it runs on a +hosted CI runner (where `mcpm up` cannot). + +```yaml +# .github/workflows/mcpm.yml +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: getmcpm/cli/.github/actions/mcpm-verify@v0.19.0 # or: run: npx @getmcpm/cli verify +``` + +The Action writes a job step summary from `--json`; the same verb works as a +pre-commit hook. See [`.github/actions/mcpm-verify`](.github/actions/mcpm-verify). + +> Honesty boundary: a failure means npm's *published record* diverged from your +> lock — not that mcpm caught malicious bytes; npx/uvx fetch the artifact +> independently at server launch. + ## Runtime defense (mcpm-guard) Install-time trust scoring catches most poisoned servers before they ship. But what about **rug-pulls** — a server that changes its tool definitions after you've already approved them? Or **prompt-injection in tool responses** — adversarial text embedded in a Slack message, web page, or calendar invite that the agent reads through your trusted MCP server? diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index f66a903..bc429af 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -14,6 +14,7 @@ do-not-proceed. | `mcpm --version` | always | — | prints `X.Y.Z` | | `mcpm up` | applied cleanly | `1` | blocks the run when any server is **blocked** (trust floor, integrity, policy) or **failed**, regardless of `--ci` | | `mcpm up --frozen` | lockfile verified, applied | `1` | fail-closed pre-install verify: blocks on integrity drift, an unverifiable record, a format mismatch, or a missing stack/lock | +| `mcpm verify` | lockfile integrity verified | `1` | repo-only, **client-free** CI gate: the same fail-closed integrity pass as `up --frozen` (drift / unverifiable / format mismatch / suspicious missing baseline), plus `1` when no lock file is found. `--json` emits the verify model | | `mcpm up --ci` | applied, no prompts | `1` | non-interactive; also non-zero on shadow collisions when combined with `--check-shadowing` | | `mcpm sync --check` | all clients in sync | **`2`** on drift/conflict; `1` on error | **`2` is the drift signal** — the value CI consumes. `--json` emits the drift model | | `mcpm audit` | scan complete | `1` when overall trust level is **risky** | advisory findings (e.g. a delisted/deprecated server) lower the score but do not by themselves flip the exit | @@ -34,7 +35,7 @@ new failure modes, but the meanings above will not be repurposed within `0.x`. ## `--json` output (mostly UNSTABLE for now) `--json` is available on `search`, `install`, `list`, `info`, `audit`, `update`, -`outdated`, `diff`, `sync`, `why`, `doctor`, `guard list-signatures`, and +`outdated`, `diff`, `sync`, `why`, `doctor`, `verify`, `guard list-signatures`, and `guard doctor-confine`. **Treat these shapes as unstable in `0.x`** — fields may be added or renamed — with one exception: diff --git a/docs/ROADMAP-ADOPTION.md b/docs/ROADMAP-ADOPTION.md index 251161f..7e38ffc 100644 --- a/docs/ROADMAP-ADOPTION.md +++ b/docs/ROADMAP-ADOPTION.md @@ -176,7 +176,17 @@ The completeness critic's verdict: the candidate set over-indexed enterprise for backend — no server names/args). `.github/ISSUE_TEMPLATE/bug.yml` requires a pasted report — the telemetry-free friction channel (flutter/brew/gh norm). **Cut:** PATH-origin classification (that's F9-PR2; don't smuggle it in). -- **D2 · `mcpm verify` + GitHub Action.** The critique killed the "thin wrapper" +- **D2 · `mcpm verify` + GitHub Action. ✅ SHIPPED** (on `main`, ships next tag; see + CHANGELOG `[Unreleased]`). Confirmed the critique: `classifyIntegrity` + a new pure + `frozenVerdict` were extracted from `up.ts` into `src/stack/frozen-verify.ts`, and + `mcpm verify [--json]` runs that pass **client-free** (no client detection, no + `~/.mcpm`, no writes) with the same BLOCK semantics + exit codes as `up --frozen` + (exit 1 on drift / unverifiable / format mismatch / mixed missing baseline; benign + refuse on a lock-wide no-baseline; pypi/oci/url reported as unenforceable). Composite + Action at `.github/actions/mcpm-verify` (step summary from `--json`, static badge, + pre-commit snippet). `up`'s block matrix is byte-identical (13 frozen tests + unchanged). **v1 scope = npm `dist.integrity`**; stack-vs-lock staleness deferred. + Original plan: The critique killed the "thin wrapper" framing: none of the existing commands run on a hosted runner (`up` hard-fails on zero detected clients; `sync --check`/`audit` are vacuously green there). First a CLI PR: **`mcpm verify [--json]`** — repo-only, client-free; loads diff --git a/src/__tests__/commands/verify.test.ts b/src/__tests__/commands/verify.test.ts new file mode 100644 index 0000000..8837260 --- /dev/null +++ b/src/__tests__/commands/verify.test.ts @@ -0,0 +1,159 @@ +/** + * D2 tests: `mcpm verify` — the repo-only, client-free lockfile integrity gate. + * + * verifyHandler injects parseLock + fetchNpmIntegrity, so the block matrix is + * tested with in-memory locks (no temp files, no client detection). It mirrors the + * up --frozen matrix because both consume the SAME shared frozenVerdict — here we + * assert the verify-flavored verdict/model/exit-code, not up's install text. + */ + +import { describe, it, expect, vi } from "vitest"; +import { verifyHandler, type VerifyDeps, type VerifyModel } from "../../commands/verify.js"; +import type { LockFile, NpmIntegritySnapshot } from "../../stack/schema.js"; + +const SRI_OLD = "sha512-" + "A".repeat(86) + "=="; +const SRI_NEW = "sha512-" + "B".repeat(86) + "=="; + +const TRUST = { score: 75, maxPossible: 80, level: "safe", assessedAt: "2026-04-05T10:00:00Z" }; + +type Entry = Record; + +function npmEntry(identifier: string, integrity?: string): Entry { + return { + version: "1.0.0", + registryType: "npm", + identifier, + trust: TRUST, + ...(integrity ? { npmIntegrity: { npmVersion: "1.0.0", integrity } } : {}), + }; +} + +function pypiEntry(identifier: string): Entry { + return { version: "1.0.0", registryType: "pypi", identifier, trust: TRUST }; +} + +function lockOf(servers: Record): LockFile { + return { lockfileVersion: 1, lockedAt: "2026-04-05T10:00:00Z", servers } as unknown as LockFile; +} + +function deps( + lock: LockFile | null, + fetch: (id: string, v: string) => Promise +): { deps: VerifyDeps; out: () => string; fetch: ReturnType } { + const lines: string[] = []; + const fetchMock = vi.fn(fetch); + return { + deps: { + parseLock: vi.fn().mockResolvedValue(lock), + fetchNpmIntegrity: fetchMock, + output: (t: string) => lines.push(t), + }, + out: () => lines.join("\n"), + fetch: fetchMock, + }; +} + +const snap = (integrity: string): NpmIntegritySnapshot => + ({ npmVersion: "1.0.0", integrity }) as NpmIntegritySnapshot; + +describe("verifyHandler — block matrix", () => { + it("all integrity equal → ok, exit 0, verified count", async () => { + const d = deps(lockOf({ a: npmEntry("@test/a", SRI_OLD) }), async () => snap(SRI_OLD)); + const code = await verifyHandler(d.deps); + expect(code).toBe(0); + expect(d.out()).toMatch(/✓ 1 npm server verified/); + }); + + it("integrity drift → BLOCK, exit 1", async () => { + const d = deps(lockOf({ a: npmEntry("@test/a", SRI_OLD) }), async () => snap(SRI_NEW)); + const code = await verifyHandler(d.deps); + expect(code).toBe(1); + expect(d.out()).toMatch(/integrity drift/i); + expect(d.out()).toMatch(/verification failed: 1 server/); + }); + + it("could-not-verify (fetch returns undefined) → BLOCK, exit 1", async () => { + const d = deps(lockOf({ a: npmEntry("@test/a", SRI_OLD) }), async () => undefined); + const code = await verifyHandler(d.deps); + expect(code).toBe(1); + expect(d.out()).toMatch(/could not verify/i); + }); + + it("format-only mismatch (incomparable algorithms) → BLOCK, exit 1", async () => { + const d = deps( + lockOf({ a: npmEntry("@test/a", "sha1-" + "A".repeat(27) + "=") }), + async () => snap(SRI_NEW) + ); + const code = await verifyHandler(d.deps); + expect(code).toBe(1); + expect(d.out()).toMatch(/integrity format changed/i); + }); + + it("lock-wide no baseline → benign refuse (exit 1), NEVER fetches", async () => { + const d = deps(lockOf({ a: npmEntry("@test/a"), b: npmEntry("@test/b") }), async () => snap(SRI_OLD)); + const code = await verifyHandler(d.deps); + expect(code).toBe(1); + expect(d.out()).toMatch(/no integrity baselines/i); + expect(d.fetch).not.toHaveBeenCalled(); + }); + + it("mixed gap (one npm server has a baseline, another doesn't) → BLOCK, exit 1", async () => { + const d = deps( + lockOf({ a: npmEntry("@test/a", SRI_OLD), b: npmEntry("@test/b") }), + async () => snap(SRI_OLD) + ); + const code = await verifyHandler(d.deps); + expect(code).toBe(1); + expect(d.out()).toMatch(/no integrity baseline recorded/i); + }); + + it("pypi-only lock → coverage notice, ok, exit 0 (never a refuse-to-run)", async () => { + const d = deps(lockOf({ p: pypiEntry("test-p") }), async () => snap(SRI_OLD)); + const code = await verifyHandler(d.deps); + expect(code).toBe(0); + expect(d.out()).toMatch(/cannot enforce/i); + expect(d.out()).not.toMatch(/no integrity baselines/i); + }); + + it("no lock file → exit 1 with a run-mcpm-lock message", async () => { + const d = deps(null, async () => snap(SRI_OLD)); + const code = await verifyHandler(d.deps); + expect(code).toBe(1); + expect(d.out()).toMatch(/no lock file found.*mcpm lock/i); + }); +}); + +describe("verifyHandler — honesty + --json", () => { + it("block copy never over-claims it stopped the code", async () => { + const d = deps(lockOf({ a: npmEntry("@test/a", SRI_OLD) }), async () => snap(SRI_NEW)); + await verifyHandler(d.deps); + const o = d.out(); + expect(o).toMatch(/published record/i); + expect(o).toMatch(/not the code your agent runs/i); + expect(o).not.toMatch(/different bytes|you are protected|blocked the attack|is safe/i); + }); + + it("--json emits the structured model with the block classification", async () => { + const d = deps(lockOf({ a: npmEntry("@test/a", SRI_OLD) }), async () => snap(SRI_NEW)); + const code = await verifyHandler(d.deps, { json: true }); + expect(code).toBe(1); + const model = JSON.parse(d.out()) as VerifyModel; + expect(model.schemaVersion).toBe(1); + expect(model.ok).toBe(false); + expect(model.blocked).toHaveLength(1); + expect(model.blocked[0]).toMatchObject({ name: "a", reason: "drift", identifier: "@test/a" }); + }); + + it("--json on a clean lock reports ok + verified count", async () => { + const d = deps( + lockOf({ a: npmEntry("@test/a", SRI_OLD), b: npmEntry("@test/b", SRI_OLD) }), + async () => snap(SRI_OLD) + ); + const code = await verifyHandler(d.deps, { json: true }); + expect(code).toBe(0); + const model = JSON.parse(d.out()) as VerifyModel; + expect(model.ok).toBe(true); + expect(model.verified).toBe(2); + expect(model.checkedNpmCount).toBe(2); + }); +}); diff --git a/src/commands/completions.ts b/src/commands/completions.ts index bd1897b..11f98c5 100644 --- a/src/commands/completions.ts +++ b/src/commands/completions.ts @@ -34,7 +34,7 @@ export interface CompletionsDeps { export const SUBCOMMANDS = [ "search", "install", "info", "list", "remove", "audit", "update", "outdated", "doctor", "init", "import", "serve", "disable", "enable", "alias", "export", - "lock", "up", "diff", "sync", "publish", "guard", "secrets", "why", "completions", + "lock", "up", "verify", "diff", "sync", "publish", "guard", "secrets", "why", "completions", ]; export const GUARD_SUBCOMMANDS = @@ -108,6 +108,7 @@ _mcpm() { 'export:Export installed servers to a stack file' 'lock:Resolve and lock a stack file (mcpm.yaml -> mcpm-lock.yaml)' 'up:Install servers from a stack file with trust policy' + 'verify:Verify lockfile integrity against npm (CI gate)' 'diff:Compare installed state vs declared stack' 'sync:Show cross-client config drift' 'publish:Publish a server to the registry' @@ -168,6 +169,7 @@ complete -c mcpm -n '__fish_use_subcommand' -a alias -d 'Manage server aliases' complete -c mcpm -n '__fish_use_subcommand' -a export -d 'Export to a stack file' complete -c mcpm -n '__fish_use_subcommand' -a lock -d 'Lock a stack file' complete -c mcpm -n '__fish_use_subcommand' -a up -d 'Install from a stack file' +complete -c mcpm -n '__fish_use_subcommand' -a verify -d 'Verify lockfile integrity (CI gate)' complete -c mcpm -n '__fish_use_subcommand' -a diff -d 'Compare installed vs declared' complete -c mcpm -n '__fish_use_subcommand' -a sync -d 'Show cross-client config drift' complete -c mcpm -n '__fish_use_subcommand' -a publish -d 'Publish a server to the registry' diff --git a/src/commands/index.ts b/src/commands/index.ts index 3e894e7..2abd786 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -24,6 +24,7 @@ import { registerAliasCommand } from "./alias.js"; import { registerExportCommand } from "./export.js"; import { registerLockCommand } from "./lock.js"; import { registerUpCommand } from "./up.js"; +import { registerVerifyCommand } from "./verify.js"; import { registerDiffCommand } from "./diff.js"; import { registerSyncCommand } from "./sync.js"; import { registerPublishCommand } from "./publish/index.js"; @@ -62,6 +63,8 @@ export { registerLockCommand, handleLock } from "./lock.js"; export type { LockDeps, LockOptions } from "./lock.js"; export { registerUpCommand, handleUp } from "./up.js"; export type { UpDeps, UpOptions } from "./up.js"; +export { registerVerifyCommand, verifyHandler } from "./verify.js"; +export type { VerifyDeps, VerifyOpts, VerifyModel } from "./verify.js"; export { registerDiffCommand, handleDiff } from "./diff.js"; export type { DiffDeps, DiffOptions } from "./diff.js"; export { registerSyncCommand, handleSync } from "./sync.js"; @@ -96,6 +99,7 @@ export function registerCommands(program: Command): void { registerExportCommand(program); registerLockCommand(program); registerUpCommand(program); + registerVerifyCommand(program); registerDiffCommand(program); registerSyncCommand(program); registerOutdatedCommand(program); diff --git a/src/commands/up.ts b/src/commands/up.ts index 0f7f37a..cabc18d 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -46,7 +46,7 @@ import { extractRegistryMeta } from "../utils/format-trust.js"; import { assessServerStatus } from "../scanner/registry-status.js"; import { applyKeychainSecrets, type SecretsMode, setSecrets as _setSecrets } from "../store/keychain.js"; import { isNewUnguarded } from "../guard/unguarded.js"; -import { compareIntegrity } from "../registry/npm-integrity.js"; +import { classifyIntegrity, frozenVerdict } from "../stack/frozen-verify.js"; import type { PinsFile } from "../guard/pins.js"; import { readPins as _readPins } from "../guard/pins.js"; import { @@ -651,95 +651,13 @@ async function processServer(input: ProcessInput): Promise { * include the clause "mcpm checks the registry's published record, not the code * your agent runs." NEVER write "serving different bytes" or claim protection. */ -/** A locked registry server entry (npm or other), as stored in mcpm-lock.yaml. */ -type LockedRegistryEntry = { - version: string; - registryType: string; - identifier: string; - trust: unknown; - npmIntegrity?: { npmVersion: string; integrity: string }; -}; - -/** Server coordinate carried through integrity classification. */ -type IntegrityCoord = { name: string; identifier: string; npmVersion: string }; - -interface IntegrityClassification { - /** locked dist.integrity differs from npm's current published record. */ - readonly drift: (IntegrityCoord & { oldIntegrity: string; newIntegrity: string })[]; - /** npm's record uses no algorithm in common with the lock — cannot compare. */ - readonly formatOnly: IntegrityCoord[]; - /** fetch returned nothing (offline / 404 / no comparable dist.integrity). */ - readonly couldNotVerify: IntegrityCoord[]; - /** npm servers whose lock entry has no npmIntegrity baseline at all. */ - readonly absentBaseline: string[]; - /** servers --frozen cannot integrity-check at all: non-npm registry servers - * (pypi/oci — no baseline mechanism yet) plus url servers (no package coordinate). */ - readonly unenforceable: string[]; - /** count of npm servers that DID have a baseline (to tell lock-wide vs mixed gaps). */ - readonly checkedNpmCount: number; -} - -/** - * Shared integrity classifier (H11). Fetches npm's current published dist.integrity - * for every locked npm server with a baseline (one batched Promise.all) and sorts - * every locked registry server into buckets. Pure of output — both the WARN pass - * (runIntegrityPass) and the fail-closed BLOCK pass (runFrozenPass) consume it, so - * the fetch/compare logic lives in exactly one place. - */ -async function classifyIntegrity( - lockFile: LockFile, - deps: Pick -): Promise { - const registryEntries = Object.entries(lockFile.servers).filter(([, locked]) => - isLockedRegistryServer(locked) - ) as [string, LockedRegistryEntry][]; - - const npmEntries = registryEntries.filter(([, l]) => l.registryType === "npm"); - // Everything that is NOT an npm registry server can't be integrity-checked: - // pypi/oci (no baseline mechanism yet) and url servers (no package coordinate). - const npmNames = new Set(npmEntries.map(([name]) => name)); - const unenforceable = Object.keys(lockFile.servers).filter((name) => !npmNames.has(name)); - - const checkable = npmEntries.filter(([, l]) => l.npmIntegrity !== undefined); - const absentBaseline = npmEntries - .filter(([, l]) => l.npmIntegrity === undefined) - .map(([name]) => name); - - const fresh = await Promise.all( - checkable.map(([, l]) => deps.fetchNpmIntegrity(l.identifier, l.npmIntegrity!.npmVersion)) - ); - - const drift: IntegrityClassification["drift"] = []; - const formatOnly: IntegrityCoord[] = []; - const couldNotVerify: IntegrityCoord[] = []; - - for (let i = 0; i < checkable.length; i++) { - const [name, locked] = checkable[i]!; - const baseline = locked.npmIntegrity!; - const snap = fresh[i]; - const coord: IntegrityCoord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion }; - - if (snap === undefined) { - couldNotVerify.push(coord); - continue; - } - const cmp = compareIntegrity(baseline.integrity, snap.integrity); - if (cmp === "equal") continue; - if (cmp === "differ") { - drift.push({ ...coord, oldIntegrity: baseline.integrity, newIntegrity: snap.integrity }); - } else { - formatOnly.push(coord); - } - } - - return { drift, formatOnly, couldNotVerify, absentBaseline, unenforceable, checkedNpmCount: checkable.length }; -} +// classifyIntegrity + its types moved to ../stack/frozen-verify.ts (shared with `mcpm verify`). async function runIntegrityPass( lockFile: LockFile, deps: Pick ): Promise { - const c = await classifyIntegrity(lockFile, deps); + const c = await classifyIntegrity(lockFile, deps.fetchNpmIntegrity); // Emit drift advisories (one per server). for (const d of c.drift) { @@ -808,69 +726,65 @@ async function runFrozenPass( lockFile: LockFile, deps: Pick ): Promise { - const c = await classifyIntegrity(lockFile, deps); + const v = frozenVerdict(await classifyIntegrity(lockFile, deps.fetchNpmIntegrity)); // Servers --frozen can't integrity-check (pypi/oci have no baseline mechanism; // url servers have no package coordinate) — name them loudly rather than let a // clean result read as a full freeze (multi-registry pinning is deferred). - if (c.unenforceable.length > 0) { + if (v.unenforceable.length > 0) { deps.output( - `\n${c.unenforceable.length} server(s) (pypi/oci/url) have no integrity baseline mechanism` + + `\n${v.unenforceable.length} server(s) (pypi/oci/url) have no integrity baseline mechanism` + ` — \`--frozen\` cannot enforce them (multi-registry pinning is deferred).` ); } // A lock where NO npm server has a baseline is benign (pre-baseline or offline // lock), not an attack — refuse with instructions instead of a per-server verdict. - if (c.absentBaseline.length > 0 && c.checkedNpmCount === 0) { + if (v.noBaselines) { throw new Error( "--frozen: this lock has no integrity baselines (it predates them, or was last locked" + " offline). Run `mcpm lock` online once to record them, then `mcpm up --frozen`." ); } - const blocks: string[] = []; - for (const d of c.drift) { - const oldShort = d.oldIntegrity.slice(0, 16); - const newShort = d.newIntegrity.slice(0, 16); - blocks.push( - `✗ FROZEN: npm's published record for ${d.identifier}@${d.npmVersion} changed since you` + - ` locked it (dist.integrity ${oldShort}… → ${newShort}…). --frozen refuses to install on` + - ` integrity drift. Re-pin with \`mcpm lock\` only if this change is expected.` - ); - } - for (const f of c.formatOnly) { - blocks.push( - `✗ FROZEN: cannot compare npm's published record for ${f.identifier}@${f.npmVersion} against` + - ` your locked baseline (integrity format changed). Re-run \`mcpm lock\` to refresh it.` - ); - } - // could-not-verify is NON-deterministic (a transient registry blip looks the same - // as a yanked version) — give it a distinct "re-run" message, separate from the - // deterministic drift block. - for (const v of c.couldNotVerify) { - blocks.push( - `✗ FROZEN: could not verify npm's published record for ${v.identifier}@${v.npmVersion} this` + - ` run (offline, a yanked version, or no comparable dist.integrity). --frozen requires proof` + - ` the record matches your lock — this may be a transient registry error, so re-run; if it` + - ` persists, drop --frozen.` - ); - } - // mixed-lock gap (handled above only when checkedNpmCount === 0). - for (const name of c.absentBaseline) { - blocks.push( - `✗ FROZEN: no integrity baseline recorded for ${name}, though other servers in this lock` + - ` have one. Re-run \`mcpm lock\` online to record it, then \`mcpm up --frozen\`.` - ); - } + if (v.ok) return; - if (blocks.length > 0) { - deps.output(`\n${blocks.join("\n")}`); - deps.output("\nmcpm verifies the registry's published record, not the code your agent runs at launch."); - throw new Error( - `frozen: ${blocks.length} server(s) failed integrity verification; nothing was installed.` - ); - } + const blocks = v.blocks.map((b) => { + switch (b.reason) { + case "drift": + return ( + `✗ FROZEN: npm's published record for ${b.identifier}@${b.npmVersion} changed since you` + + ` locked it (dist.integrity ${b.oldIntegrity.slice(0, 16)}… → ${b.newIntegrity.slice(0, 16)}…). --frozen refuses to install on` + + ` integrity drift. Re-pin with \`mcpm lock\` only if this change is expected.` + ); + case "format": + return ( + `✗ FROZEN: cannot compare npm's published record for ${b.identifier}@${b.npmVersion} against` + + ` your locked baseline (integrity format changed). Re-run \`mcpm lock\` to refresh it.` + ); + case "could-not-verify": + // NON-deterministic (a transient registry blip looks the same as a yanked + // version) — distinct "re-run" message, separate from deterministic drift. + return ( + `✗ FROZEN: could not verify npm's published record for ${b.identifier}@${b.npmVersion} this` + + ` run (offline, a yanked version, or no comparable dist.integrity). --frozen requires proof` + + ` the record matches your lock — this may be a transient registry error, so re-run; if it` + + ` persists, drop --frozen.` + ); + case "missing-baseline": + // Mixed-lock gap (the lock-wide case is the benign refuse-to-run above). + return ( + `✗ FROZEN: no integrity baseline recorded for ${b.name}, though other servers in this lock` + + ` have one. Re-run \`mcpm lock\` online to record it, then \`mcpm up --frozen\`.` + ); + } + }); + + deps.output(`\n${blocks.join("\n")}`); + deps.output("\nmcpm verifies the registry's published record, not the code your agent runs at launch."); + throw new Error( + `frozen: ${blocks.length} server(s) failed integrity verification; nothing was installed.` + ); } /** diff --git a/src/commands/verify.ts b/src/commands/verify.ts new file mode 100644 index 0000000..4c579e9 --- /dev/null +++ b/src/commands/verify.ts @@ -0,0 +1,195 @@ +/** + * `mcpm verify` — repo-only, client-free lockfile integrity gate (D2). + * + * Unlike `mcpm up --frozen`, this runs on a hosted CI runner with ZERO AI clients + * installed: it loads `mcpm-lock.yaml` and runs the shared frozen verify pass + * (`classifyIntegrity` + `frozenVerdict`) with the SAME block semantics — integrity + * drift / unverifiable record / format mismatch / suspicious missing baseline → BLOCK + * (exit 1). It does NOT detect clients, read `~/.mcpm`, or write anything. + * + * ONE verb: B3 later extends `mcpm verify` with Sigstore provenance. Integrity now, + * provenance later — never two meanings. + * + * HONESTY BOUNDARY (inherited from F3): a block means npm's PUBLISHED RECORD diverged + * from your lock — NOT that mcpm caught malicious bytes. npx/uvx fetch the artifact + * independently at server launch. + */ + +import type { LockFile } from "../stack/schema.js"; +import { + classifyIntegrity, + frozenVerdict, + type FetchNpmIntegrity, + type FrozenBlock, +} from "../stack/frozen-verify.js"; + +export interface VerifyDeps { + /** Returns the parsed lock, or null when the file does not exist. */ + parseLock: (path: string) => Promise; + fetchNpmIntegrity: FetchNpmIntegrity; + output: (text: string) => void; +} + +export interface VerifyBlocked { + name: string; + reason: FrozenBlock["reason"]; + identifier?: string; + npmVersion?: string; +} + +export interface VerifyModel { + schemaVersion: 1; + ok: boolean; + /** npm servers whose published record matched the lock. */ + verified: number; + /** npm servers that had a baseline to check. */ + checkedNpmCount: number; + /** the benign refuse-to-run: the whole lock predates baselines / was locked offline. */ + noBaselines: boolean; + blocked: VerifyBlocked[]; + /** pypi/oci/url servers with no baseline mechanism — reported, never blocked. */ + unenforceable: string[]; + /** set only when the lock could not be loaded at all. */ + error?: string; +} + +export interface VerifyOpts { + json?: boolean; + /** Path to mcpm.yaml; the lock is derived as `-lock.yaml`. Default `mcpm.yaml`. */ + stackFile?: string; +} + +/** + * @returns exit code: 0 = verified, 1 = block / could-not-load. + */ +export async function verifyHandler(deps: VerifyDeps, opts: VerifyOpts = {}): Promise { + const stackPath = opts.stackFile ?? "mcpm.yaml"; + const lockPath = stackPath.replace(/\.yaml$/, "-lock.yaml"); + + const lockFile = await deps.parseLock(lockPath); + if (lockFile === null) { + // Deterministic gate: verify NEVER auto-locks (that would defeat the point in CI). + const error = `no lock file found at ${lockPath} — run \`mcpm lock\` first.`; + const model: VerifyModel = { + schemaVersion: 1, + ok: false, + verified: 0, + checkedNpmCount: 0, + noBaselines: false, + blocked: [], + unenforceable: [], + error, + }; + if (opts.json) deps.output(JSON.stringify(model, null, 2)); + else deps.output(`\n✗ ${error}`); + return 1; + } + + const v = frozenVerdict(await classifyIntegrity(lockFile, deps.fetchNpmIntegrity)); + + const blocked: VerifyBlocked[] = v.blocks.map((b) => + b.reason === "missing-baseline" + ? { name: b.name, reason: b.reason } + : { name: b.name, reason: b.reason, identifier: b.identifier, npmVersion: b.npmVersion } + ); + // "checkable" npm servers minus those that failed a checkable reason. missing-baseline + // blocks are NOT in checkedNpmCount, so they don't subtract here. + const failedCheckable = v.blocks.filter((b) => b.reason !== "missing-baseline").length; + + const model: VerifyModel = { + schemaVersion: 1, + ok: v.ok, + verified: v.checkedNpmCount - failedCheckable, + checkedNpmCount: v.checkedNpmCount, + noBaselines: v.noBaselines, + blocked, + unenforceable: v.unenforceable, + }; + + if (opts.json) { + deps.output(JSON.stringify(model, null, 2)); + } else { + renderVerifyText(model, deps.output); + } + return model.ok ? 0 : 1; +} + +const REASON_PHRASE: Record = { + drift: "npm's published record changed since you locked it (integrity drift) — re-pin with `mcpm lock` only if expected", + format: + "cannot compare npm's published record against your locked baseline (integrity format changed) — re-run `mcpm lock`", + "could-not-verify": + "could not verify npm's published record this run (offline, a yanked version, or no comparable dist.integrity) — re-run; if it persists, investigate before dropping the check", + "missing-baseline": + "no integrity baseline recorded, though other servers in this lock have one — re-run `mcpm lock` online", +}; + +function renderVerifyText(model: VerifyModel, output: (text: string) => void): void { + output(""); + output("mcpm verify"); + output(""); + + if (model.noBaselines) { + output( + " ✗ this lock has no integrity baselines (it predates them, or was last locked offline)." + ); + output(" Run `mcpm lock` online once to record them, then `mcpm verify`."); + return; + } + + if (model.unenforceable.length > 0) { + output( + ` ⚠ ${model.unenforceable.length} server(s) (pypi/oci/url) have no integrity baseline mechanism — verify cannot enforce them (multi-registry pinning is deferred).` + ); + } + + if (model.ok) { + const word = model.verified === 1 ? "server" : "servers"; + output(` ✓ ${model.verified} npm ${word} verified against npm's published record.`); + return; + } + + for (const b of model.blocked) { + const who = b.identifier ? `${b.identifier}@${b.npmVersion}` : b.name; + output(` ✗ ${who}: ${REASON_PHRASE[b.reason]}`); + } + output(""); + output( + `verification failed: ${model.blocked.length} server(s). mcpm checks the registry's published record, not the code your agent runs.` + ); +} + +// --------------------------------------------------------------------------- +// Commander registration +// --------------------------------------------------------------------------- + +import { Command } from "commander"; +import chalk from "chalk"; +import { parseLockFile } from "../stack/schema.js"; +import { fetchNpmIntegrity as _fetchNpmIntegrity } from "../registry/npm-integrity.js"; + +function coloredOutput(text: string): void { + if (text.startsWith(" ✓")) console.log(chalk.green(text)); + else if (text.startsWith(" ✗")) console.log(chalk.red(text)); + else if (text.startsWith(" ⚠")) console.log(chalk.yellow(text)); + else console.log(text); +} + +export function registerVerifyCommand(program: Command): void { + program + .command("verify") + .description("Verify mcpm-lock.yaml integrity against npm's published record (repo-only CI gate)") + .option("--json", "emit the structured verify model as JSON (shape UNSTABLE)") + .option("-f, --file ", "path to mcpm.yaml (the lock is derived as -lock.yaml)") + .action(async (opts: { json?: boolean; file?: string }) => { + const code = await verifyHandler( + { + parseLock: parseLockFile, + fetchNpmIntegrity: (id, v) => _fetchNpmIntegrity(id, v), + output: opts.json ? (t) => console.log(t) : coloredOutput, + }, + { json: opts.json, stackFile: opts.file } + ); + process.exit(code); + }); +} diff --git a/src/stack/frozen-verify.ts b/src/stack/frozen-verify.ts new file mode 100644 index 0000000..4814db7 --- /dev/null +++ b/src/stack/frozen-verify.ts @@ -0,0 +1,173 @@ +/** + * Shared lockfile integrity verification (F3 / D2). + * + * `classifyIntegrity` fetches npm's current published `dist.integrity` for every + * locked npm server with a baseline and sorts every locked registry server into + * buckets. `frozenVerdict` turns that classification into a structured pass/block + * decision. Both are CLIENT-FREE and pure of output, so: + * - `up --frozen` (the pre-install gate) consumes them and renders install-flavored + * block text, and + * - `mcpm verify` (D2 — the repo-only CI gate) consumes them on a hosted runner + * where zero AI clients are installed, rendering verify-flavored text / `--json`. + * + * HONESTY BOUNDARY: a block means npm's PUBLISHED RECORD diverged from (or can't be + * matched against) your lock — NOT that mcpm caught malicious bytes. npx/uvx fetch the + * artifact independently at server launch, so this is a deterministic tripwire on the + * registry's published metadata, not code interception. + */ + +import type { LockFile, NpmIntegritySnapshot } from "./schema.js"; +import { isLockedRegistryServer } from "./schema.js"; +import { compareIntegrity } from "../registry/npm-integrity.js"; + +/** The npm-integrity fetcher (injected so tests and the CLI share one signature). */ +export type FetchNpmIntegrity = ( + identifier: string, + npmVersion: string +) => Promise; + +/** A locked registry server entry (npm or other), as stored in mcpm-lock.yaml. */ +type LockedRegistryEntry = { + version: string; + registryType: string; + identifier: string; + trust: unknown; + npmIntegrity?: { npmVersion: string; integrity: string }; +}; + +/** Server coordinate carried through integrity classification. */ +export type IntegrityCoord = { name: string; identifier: string; npmVersion: string }; + +export interface IntegrityClassification { + /** locked dist.integrity differs from npm's current published record. */ + readonly drift: (IntegrityCoord & { oldIntegrity: string; newIntegrity: string })[]; + /** npm's record uses no algorithm in common with the lock — cannot compare. */ + readonly formatOnly: IntegrityCoord[]; + /** fetch returned nothing (offline / 404 / no comparable dist.integrity). */ + readonly couldNotVerify: IntegrityCoord[]; + /** npm servers whose lock entry has no npmIntegrity baseline at all. */ + readonly absentBaseline: string[]; + /** servers integrity-verification cannot check at all: non-npm registry servers + * (pypi/oci — no baseline mechanism yet) plus url servers (no package coordinate). */ + readonly unenforceable: string[]; + /** count of npm servers that DID have a baseline (to tell lock-wide vs mixed gaps). */ + readonly checkedNpmCount: number; +} + +/** + * Fetches npm's current published dist.integrity for every locked npm server with a + * baseline (one batched Promise.all) and sorts every locked registry server into + * buckets. Pure of output. + */ +export async function classifyIntegrity( + lockFile: LockFile, + fetchNpmIntegrity: FetchNpmIntegrity +): Promise { + const registryEntries = Object.entries(lockFile.servers).filter(([, locked]) => + isLockedRegistryServer(locked) + ) as [string, LockedRegistryEntry][]; + + const npmEntries = registryEntries.filter(([, l]) => l.registryType === "npm"); + // Everything that is NOT an npm registry server can't be integrity-checked: + // pypi/oci (no baseline mechanism yet) and url servers (no package coordinate). + const npmNames = new Set(npmEntries.map(([name]) => name)); + const unenforceable = Object.keys(lockFile.servers).filter((name) => !npmNames.has(name)); + + const checkable = npmEntries.filter(([, l]) => l.npmIntegrity !== undefined); + const absentBaseline = npmEntries + .filter(([, l]) => l.npmIntegrity === undefined) + .map(([name]) => name); + + const fresh = await Promise.all( + checkable.map(([, l]) => fetchNpmIntegrity(l.identifier, l.npmIntegrity!.npmVersion)) + ); + + const drift: IntegrityClassification["drift"] = []; + const formatOnly: IntegrityCoord[] = []; + const couldNotVerify: IntegrityCoord[] = []; + + for (let i = 0; i < checkable.length; i++) { + const [name, locked] = checkable[i]!; + const baseline = locked.npmIntegrity!; + const snap = fresh[i]; + const coord: IntegrityCoord = { name, identifier: locked.identifier, npmVersion: baseline.npmVersion }; + + if (snap === undefined) { + couldNotVerify.push(coord); + continue; + } + const cmp = compareIntegrity(baseline.integrity, snap.integrity); + if (cmp === "equal") continue; + if (cmp === "differ") { + drift.push({ ...coord, oldIntegrity: baseline.integrity, newIntegrity: snap.integrity }); + } else { + formatOnly.push(coord); + } + } + + return { drift, formatOnly, couldNotVerify, absentBaseline, unenforceable, checkedNpmCount: checkable.length }; +} + +// --------------------------------------------------------------------------- +// Verdict — the shared pass/block decision (rendered by each command's own text) +// --------------------------------------------------------------------------- + +export type FrozenBlock = + | { name: string; reason: "drift"; identifier: string; npmVersion: string; oldIntegrity: string; newIntegrity: string } + | { name: string; reason: "format"; identifier: string; npmVersion: string } + | { name: string; reason: "could-not-verify"; identifier: string; npmVersion: string } + | { name: string; reason: "missing-baseline" }; + +export interface FrozenVerdict { + /** true iff nothing blocks and it is not the benign no-baselines refuse case. */ + readonly ok: boolean; + /** benign refuse-to-run: the whole lock predates baselines / was locked offline. */ + readonly noBaselines: boolean; + /** blocking servers, in stable order (drift → format → could-not-verify → missing-baseline). */ + readonly blocks: FrozenBlock[]; + /** pypi/oci/url servers with no baseline mechanism — a coverage notice, never a block. */ + readonly unenforceable: string[]; + /** npm servers that had a baseline to check against. */ + readonly checkedNpmCount: number; +} + +/** + * The pass/block decision. A lock where NO npm server has a baseline is benign + * (`noBaselines`) — a refuse-to-run with instructions, NOT a per-server verdict. + * When some servers DO have baselines, a missing one is a suspicious mixed gap → block. + */ +export function frozenVerdict(c: IntegrityClassification): FrozenVerdict { + const noBaselines = c.absentBaseline.length > 0 && c.checkedNpmCount === 0; + + const blocks: FrozenBlock[] = []; + for (const d of c.drift) { + blocks.push({ + name: d.name, + reason: "drift", + identifier: d.identifier, + npmVersion: d.npmVersion, + oldIntegrity: d.oldIntegrity, + newIntegrity: d.newIntegrity, + }); + } + for (const f of c.formatOnly) { + blocks.push({ name: f.name, reason: "format", identifier: f.identifier, npmVersion: f.npmVersion }); + } + for (const v of c.couldNotVerify) { + blocks.push({ name: v.name, reason: "could-not-verify", identifier: v.identifier, npmVersion: v.npmVersion }); + } + // Mixed gap only — the lock-wide-no-baseline case is `noBaselines`, handled above. + if (!noBaselines) { + for (const name of c.absentBaseline) { + blocks.push({ name, reason: "missing-baseline" }); + } + } + + return { + ok: !noBaselines && blocks.length === 0, + noBaselines, + blocks, + unenforceable: c.unenforceable, + checkedNpmCount: c.checkedNpmCount, + }; +} From 53c938aa3040368d3ef2e10e7ddc2444265fc0f5 Mon Sep 17 00:00:00 2001 From: m1ngshum <140998506+m1ngshum@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:37:04 +0800 Subject: [PATCH 2/2] harden(verify): fail-closed on any error + up.ts switch exhaustiveness (review MEDIUM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the D2 integrity gate: - verifyHandler now wraps its whole body in try/catch: a malformed / Zod-invalid lock (parseLock throws) — a plausible CI trigger via a bad merge or hand-edit — now resolves to a structured fail-closed error model + exit 1 through verify's OWN output (colored text / --json), instead of only exiting 1 via the global index.ts catch. Makes the exported handler total (never throws) for programmatic reuse. New test asserts it resolves to 1, not rejects. - up.ts's frozen block-message switch gains a default exhaustiveness guard (const _never: never), so a future 5th FrozenBlock reason fails the build rather than silently emitting undefined in a security-relevant block message (parity with verify.ts's already-exhaustive REASON_PHRASE Record). --- src/__tests__/commands/verify.test.ts | 15 +++++ src/commands/up.ts | 6 ++ src/commands/verify.ts | 89 +++++++++++++++------------ 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/__tests__/commands/verify.test.ts b/src/__tests__/commands/verify.test.ts index 8837260..51c2229 100644 --- a/src/__tests__/commands/verify.test.ts +++ b/src/__tests__/commands/verify.test.ts @@ -121,6 +121,21 @@ describe("verifyHandler — block matrix", () => { expect(code).toBe(1); expect(d.out()).toMatch(/no lock file found.*mcpm lock/i); }); + + it("fail-closed: a malformed lock (parseLock throws) → exit 1, never throws", async () => { + const lines: string[] = []; + const badDeps: VerifyDeps = { + parseLock: vi.fn().mockRejectedValue(new Error("Invalid lock file (schema)")), + fetchNpmIntegrity: vi.fn(), + output: (t: string) => lines.push(t), + }; + // Must resolve (not reject) to a non-zero code — the gate stays CLOSED on a bad lock. + const code = await verifyHandler(badDeps, { json: true }); + expect(code).toBe(1); + const model = JSON.parse(lines.join("\n")) as VerifyModel; + expect(model.ok).toBe(false); + expect(model.error).toMatch(/could not verify.*Invalid lock file/i); + }); }); describe("verifyHandler — honesty + --json", () => { diff --git a/src/commands/up.ts b/src/commands/up.ts index cabc18d..4eef7df 100644 --- a/src/commands/up.ts +++ b/src/commands/up.ts @@ -777,6 +777,12 @@ async function runFrozenPass( `✗ FROZEN: no integrity baseline recorded for ${b.name}, though other servers in this lock` + ` have one. Re-run \`mcpm lock\` online to record it, then \`mcpm up --frozen\`.` ); + default: { + // Compile-time exhaustiveness: a new FrozenBlock reason must add a case here, + // or this fails to build — never silently emit `undefined` in a block message. + const _never: never = b; + throw new Error(`unhandled frozen block reason: ${JSON.stringify(_never)}`); + } } }); diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 4c579e9..63db386 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -66,52 +66,65 @@ export async function verifyHandler(deps: VerifyDeps, opts: VerifyOpts = {}): Pr const stackPath = opts.stackFile ?? "mcpm.yaml"; const lockPath = stackPath.replace(/\.yaml$/, "-lock.yaml"); - const lockFile = await deps.parseLock(lockPath); - if (lockFile === null) { - // Deterministic gate: verify NEVER auto-locks (that would defeat the point in CI). - const error = `no lock file found at ${lockPath} — run \`mcpm lock\` first.`; + // Fail-closed: this handler NEVER throws — any error (a missing lock, a malformed / + // Zod-invalid lock that makes parseLock throw, a fetch failure) resolves to exit 1 + // with a structured error model. That keeps the CI gate closed even when the + // exported handler is reused programmatically (no ambient top-level catch). + try { + const lockFile = await deps.parseLock(lockPath); + if (lockFile === null) { + // Deterministic gate: verify NEVER auto-locks (that would defeat the point in CI). + return emitError(deps, opts, `no lock file found at ${lockPath} — run \`mcpm lock\` first.`); + } + + const v = frozenVerdict(await classifyIntegrity(lockFile, deps.fetchNpmIntegrity)); + + const blocked: VerifyBlocked[] = v.blocks.map((b) => + b.reason === "missing-baseline" + ? { name: b.name, reason: b.reason } + : { name: b.name, reason: b.reason, identifier: b.identifier, npmVersion: b.npmVersion } + ); + // "checkable" npm servers minus those that failed a checkable reason. missing-baseline + // blocks are NOT in checkedNpmCount, so they don't subtract here. + const failedCheckable = v.blocks.filter((b) => b.reason !== "missing-baseline").length; + const model: VerifyModel = { schemaVersion: 1, - ok: false, - verified: 0, - checkedNpmCount: 0, - noBaselines: false, - blocked: [], - unenforceable: [], - error, + ok: v.ok, + verified: v.checkedNpmCount - failedCheckable, + checkedNpmCount: v.checkedNpmCount, + noBaselines: v.noBaselines, + blocked, + unenforceable: v.unenforceable, }; - if (opts.json) deps.output(JSON.stringify(model, null, 2)); - else deps.output(`\n✗ ${error}`); - return 1; - } - const v = frozenVerdict(await classifyIntegrity(lockFile, deps.fetchNpmIntegrity)); - - const blocked: VerifyBlocked[] = v.blocks.map((b) => - b.reason === "missing-baseline" - ? { name: b.name, reason: b.reason } - : { name: b.name, reason: b.reason, identifier: b.identifier, npmVersion: b.npmVersion } - ); - // "checkable" npm servers minus those that failed a checkable reason. missing-baseline - // blocks are NOT in checkedNpmCount, so they don't subtract here. - const failedCheckable = v.blocks.filter((b) => b.reason !== "missing-baseline").length; + if (opts.json) { + deps.output(JSON.stringify(model, null, 2)); + } else { + renderVerifyText(model, deps.output); + } + return model.ok ? 0 : 1; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return emitError(deps, opts, `could not verify ${lockPath}: ${detail}`); + } +} +/** Emit a fail-closed error (structured under --json) and return exit code 1. */ +function emitError(deps: VerifyDeps, opts: VerifyOpts, error: string): number { const model: VerifyModel = { schemaVersion: 1, - ok: v.ok, - verified: v.checkedNpmCount - failedCheckable, - checkedNpmCount: v.checkedNpmCount, - noBaselines: v.noBaselines, - blocked, - unenforceable: v.unenforceable, + ok: false, + verified: 0, + checkedNpmCount: 0, + noBaselines: false, + blocked: [], + unenforceable: [], + error, }; - - if (opts.json) { - deps.output(JSON.stringify(model, null, 2)); - } else { - renderVerifyText(model, deps.output); - } - return model.ok ? 0 : 1; + if (opts.json) deps.output(JSON.stringify(model, null, 2)); + else deps.output(`\n✗ ${error}`); + return 1; } const REASON_PHRASE: Record = {