Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
66 changes: 66 additions & 0 deletions docs/CONTRACTS.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 13 additions & 5 deletions scripts/dogfood-confine.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions src/__tests__/smoke/cli-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading