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 6e7df71..d7c393b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All notable changes to this project will be documented in this file. machine-readable `mcpm.cdx.json` (CycloneDX) SBOM of mcpm's own dependency tree, generated from the committed `pnpm-lock.yaml`. A concrete procurement/compliance artifact for downstreams (EU CRA, SOC 2 evidence). +- **`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 @@ -18,6 +23,13 @@ All notable changes to this project will be documented in this file. `CONTRIBUTING.md`, and an [OpenSSF Scorecard](.github/workflows/scorecard.yml) workflow that publishes a project-health/supply-chain posture score. No behaviour change; these make mcpm's own security posture reviewable without asking. +- **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 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/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); 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) => {