From b4ace9871c5ac4e7b17f3105ef2bd0bd776f7d21 Mon Sep 17 00:00:00 2001 From: m1ngshum <140998506+m1ngshum@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:48:57 +0800 Subject: [PATCH 1/2] ci: macOS confine leg + CONTRACTS.md exit-code contract Wave-0 items B1a + E11 from docs/ROADMAP-ADOPTION.md. - ci.yml: a macos-latest job running `pnpm dogfood:confine`, so the flagship --confine OS-sandbox enforcement path is CI-verified (the ubuntu matrix cannot exercise Seatbelt / sandbox-exec). Full unit suite intentionally not run there (a few tests are darwin-env sensitive); the dogfood is the enforcement contract. - docs/CONTRACTS.md: the exit-code table CI can depend on (sync --check = 2 on drift, up --frozen fail-closed = 1, ...), config/lock format versions, --json stability (only sync --json frozen), and semver-exempt ~/.mcpm internals. - cli-smoke.test.ts: end-to-end exit-code assertions backing the contract (sync --check drift = 2 with a no-drift positive control; up --frozen missing stack = 1). --- .github/workflows/ci.yml | 25 ++++++++++ CHANGELOG.md | 20 ++++++++ docs/CONTRACTS.md | 66 +++++++++++++++++++++++++++ src/__tests__/smoke/cli-smoke.test.ts | 33 ++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 docs/CONTRACTS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35baee7..9717846 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,28 @@ jobs: - run: pnpm run build - run: pnpm run test:coverage + + confine-macos: + # The --confine OS sandbox (Seatbelt / sandbox-exec) is macOS-only and cannot + # be exercised by the ubuntu matrix above. This leg runs the hermetic + # end-to-end confine dogfood on a real macOS runner so the flagship + # enforcement path is CI-verified, not just verified locally. The full unit + # suite is intentionally NOT run here (a couple of tests are darwin-env + # sensitive); the dogfood is the enforcement contract that matters. + name: confine dogfood (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 10 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm dogfood:confine diff --git a/CHANGELOG.md b/CHANGELOG.md index 03615fe..8a1c985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added + +- **`docs/CONTRACTS.md` — stability contracts** — documents the exit codes CI can + depend on (notably `sync --check` → `2` on drift, `up --frozen` fail-closed → `1`), + the versioned `mcpm.yaml` / `mcpm-lock.yaml` formats, which `--json` shapes are + stable, and the semver-exempt `~/.mcpm` internals. No behaviour change — it names + guarantees that already hold. + +### Internal + +- **macOS CI leg for `--confine`** — CI now runs the hermetic `pnpm dogfood:confine` + on a `macos-latest` runner, so the flagship OS-sandbox enforcement path is + CI-verified rather than only verified locally (the ubuntu matrix can't exercise + Seatbelt / `sandbox-exec`). +- **CI-gate exit-code smoke tests** — added end-to-end assertions for the contract + above (`sync --check` drift = `2` with a positive no-drift control; `up --frozen` + missing-stack = `1`). + ## [0.16.0] - 2026-07-02 An enforcement release: `mcpm guard --confine` wraps a relayed stdio server in an OS sandbox — the guard's first *containment* primitive, complementing every prior *detection* feature. diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md new file mode 100644 index 0000000..30e1a2f --- /dev/null +++ b/docs/CONTRACTS.md @@ -0,0 +1,66 @@ +# mcpm Stability Contracts + +What you can safely automate against, and what may change without warning. mcpm is +pre-1.0 (`0.x`); this document is the promise we *do* keep in the `0.x` line, and it +tightens (never loosens) at 1.0. + +## Exit codes (stable) + +These are the contract CI and scripts should depend on. `0` = success, non-zero = +do-not-proceed. + +| Command | `0` | non-zero | Notes | +|---|---|---|---| +| `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 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 | +| `mcpm doctor` | no blocking issues | `1` | health check; the cross-client advisory section never changes the exit code | +| `mcpm install` | installed | `1` | non-zero on a policy/trust block (`--min-trust`, `--min-release-age`, a registry-**deleted** server) or any failure | +| `mcpm guard run` (relay) | child exit `0` | child's code; `1` | propagates the wrapped child's exit; **fails closed with `1`** on a confine hash mismatch, a stripped required marker, or a pins-integrity error | + +Any command exits `1` on an unhandled error. New non-zero codes may be *added* for +new failure modes, but the meanings above will not be repurposed within `0.x`. + +## Config & lockfile formats (stable, versioned) + +- **`mcpm.yaml`** carries a top-level `version: "1"`. New optional fields may be + added; a breaking change bumps this and ships a documented migration. +- **`mcpm-lock.yaml`** carries `lockfileVersion: 1`. The `integrity` block is + additive/optional (older locks still parse); a breaking change bumps the number. + +## `--json` output (mostly UNSTABLE for now) + +`--json` is available on `search`, `install`, `list`, `info`, `audit`, `update`, +`outdated`, `diff`, `sync`, `why`, `guard list-signatures`, and +`guard doctor-confine`. **Treat these shapes as unstable in `0.x`** — fields may be +added or renamed — with one exception: + +- **`mcpm sync --json`** (the drift model) is **frozen** because CI consumes it + alongside the exit-`2` contract above. + +The remaining `--json` shapes stabilize per-command as they are schema-typed and +documented; until then, pin to the exit codes, not the field names. + +## Semver-exempt internals (may change any release) + +These are implementation details, guarded by their own `format_version` fields and +in-place migrations — they are **not** part of the public contract: + +- `mcpm guard run --inner …` — the internal relay argv and its wrap-marker tokens + (`--orig-hash`, `--confine-profile-hash`, `--confine-required`). +- The `~/.mcpm/` store files — `pins.json` (`PINS_FORMAT_VERSION`), + `guard-policy.yaml`, `guard-confine.yaml` (`CONFINE_FORMAT_VERSION`), and their + `.integrity` sidecars. +- `~/.mcpm/guard-events.jsonl` — append-only; fields may be *added*. A stable, + documented event schema is planned (see the adoption roadmap's SIEM item); until + then, parse defensively (unknown fields, best-effort writes). + +## Platform support + +- **macOS, Linux** — supported and CI-tested (Ubuntu matrix; macOS runs the confine + dogfood). +- **Windows** — code paths exist (config paths, DPAPI keychain) but are **not yet + CI-verified**; treat as best-effort. `--confine` is macOS-only. diff --git a/src/__tests__/smoke/cli-smoke.test.ts b/src/__tests__/smoke/cli-smoke.test.ts index fe29cc7..7fa3c2f 100644 --- a/src/__tests__/smoke/cli-smoke.test.ts +++ b/src/__tests__/smoke/cli-smoke.test.ts @@ -114,6 +114,39 @@ describe("CLI smoke — no-op paths must not claim false success", () => { }); }); +describe("CLI smoke — CI-gate exit codes (docs/CONTRACTS.md)", () => { + it("sync --check exits 2 when clients disagree (the drift signal CI consumes)", () => { + withHome((home) => { + mkdirSync(path.join(home, ".cursor"), { recursive: true }); + mkdirSync(path.join(home, ".codeium", "windsurf"), { recursive: true }); + // cursor has `foo`; windsurf does not -> drift. + writeFileSync( + path.join(home, ".cursor", "mcp.json"), + '{"mcpServers":{"foo":{"command":"echo","args":["a"]}}}', + ); + writeFileSync(path.join(home, ".codeium", "windsurf", "mcp_config.json"), '{"mcpServers":{}}'); + const r = run(["sync", "--check"], home); + expect(r.code).toBe(2); + expect(r.out).toMatch(/foo/); + }); + }); + + it("sync --check exits 0 with no client configs (no false drift)", () => { + withHome((home) => { + const r = run(["sync", "--check"], home); + expect(r.code).toBe(0); + }); + }); + + it("up --frozen exits 1 when the stack file is missing (fail-closed, not a false pass)", () => { + withHome((home) => { + const r = run(["up", "--frozen"], home); + expect(r.code).toBe(1); + expect(r.out).toMatch(/stack file not found/i); + }); + }); +}); + describe("CLI smoke — generated completions are valid shell", () => { it("the bash completion script passes `bash -n` (syntax check)", () => { withHome((home) => { From 089d81a30a3dafe070151c7257de70b7272c84ba Mon Sep 17 00:00:00 2001 From: m1ngshum <140998506+m1ngshum@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:59:27 +0800 Subject: [PATCH 2/2] fix(dogfood): clear the whole pins sidecar set before the tamper step The macOS CI leg (added in this PR) immediately caught a latent test-isolation bug: the tamper step removed pins.json to isolate the confine gate, but left the pins.json.integrity sidecar behind. The happy-path step just before it runs a real tools/list that TOFU-writes BOTH files off-thread, so on a (slower) CI runner the sidecar had landed -> readPins saw a sidecar with no matching file -> PINS-READ-ERROR fired first and masked the CONFINE hash-mismatch gate under test. Timing-dependent, which is why it passed locally. Now removes pins.json, its .integrity sidecar, and any stale .lock so readPins sees a clean first-run state. --- scripts/dogfood-confine.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/dogfood-confine.sh b/scripts/dogfood-confine.sh index a7a2c74..c34ac1e 100755 --- a/scripts/dogfood-confine.sh +++ b/scripts/dogfood-confine.sh @@ -177,11 +177,19 @@ function runToExit(args) { } // 2. tamper — corrupt the embedded profile hash → must fail closed via the - // CONFINE gate (decide table row 3). Remove pins.json first: a prior confined - // run can leave a 0-byte pins.json (writePins touch-empty window), and a - // PINS-READ-ERROR would fail-closed for an UNRELATED reason — masking the gate - // under test. Asserting on the CONFINE-BLOCK message proves the right gate fired. - fs.rmSync(path.join(process.env.HOME, ".mcpm", "pins.json"), { force: true }); + // CONFINE gate (decide table row 3). Clear the WHOLE pins sidecar set first: + // the happy-path step above ran a real tools/list that TOFU-writes BOTH + // pins.json AND its pins.json.integrity sidecar (off-thread). Deleting only + // pins.json leaves the sidecar, so readPins sees a sidecar with no matching + // file -> PINS-READ-ERROR, which fails closed for an UNRELATED reason and masks + // the CONFINE gate under test. (Timing-dependent: locally the sidecar write may + // not have landed yet; on a slower CI runner it has — which is how the macOS CI + // leg first caught this.) Remove pins.json, its .integrity sidecar, and any + // stale .lock so readPins sees a clean first-run state. + const mcpmDir = path.join(process.env.HOME, ".mcpm"); + fs.rmSync(path.join(mcpmDir, "pins.json"), { force: true }); + fs.rmSync(path.join(mcpmDir, "pins.json.integrity"), { force: true }); + fs.rmSync(path.join(mcpmDir, "pins.json.lock"), { recursive: true, force: true }); const hi = entry.args.indexOf("--confine-profile-hash"); const tampered = entry.args.slice(); tampered[hi + 1] = "0".repeat(64);