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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

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.

### Added

- **`mcpm guard --confine` — OS-sandbox confinement for relayed stdio servers (F1)** — the first enforcement primitive in mcpm-guard. Every prior guard feature is detection: it reasons about JSON-RPC bytes and warns/blocks. But the relay is a stdio MITM — it can inspect every frame yet cannot *contain* the child MCP server it spawns; a server that decides to read `~/.ssh` or write `~/Library/LaunchAgents` never expresses that through inspectable traffic. `--confine` wraps the child in an OS sandbox so it physically cannot read secret files or persist, regardless of the JSON-RPC it emits (watch vs contain, as complementary layers). **macOS only in v1** (Linux `bwrap` deferred), via Seatbelt / `sandbox-exec`. The **standard tier** enforces: READ allow-all *except* a secret-dir denylist (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config/gh`/`gcloud`, `~/.npmrc`, `~/.docker`, `~/.kube`, `~/.netrc`, `~/.git-credentials`, `~/.cargo/credentials`, `~/.pypirc`, `~/Library/Keychains`, browser cookie stores, the MCP client config dirs, and mcpm's own `~/.mcpm`); WRITE deny all of `$HOME` *except* caches, the per-server scratch dir, system temp, and `/dev` — one rule that blocks the whole persistence class (`~/.zshrc`, LaunchAgents, PATH-shadowing `~/bin`, git hooks); NET launcher-classified — package launchers (`npx`/`uvx`/`pip`/`docker`/…) get network `all`, everything else egress-deny `none`. The store is `~/.mcpm/guard-confine.yaml` (+ `.integrity` sidecar), the source of truth for enrollment; it **fails closed** on integrity/shape/format-version mismatch (like `pins.json`). The wrap marker gains two tokens before the `--` separator — `--confine-profile-hash <sha256>` (a content hash binding marker↔stored-profile) and `--confine-required` (replicated into the IDE config so it survives a wiped store) — both neutral to `--orig-hash`. The **spawn-time decision** (in run-inner, before the relay spawns the child) treats the store as source of truth: CONFINE when enrolled + hash matches + backend available; **FAIL CLOSED** (refuse to start, exit 1) on hash mismatch, a malformed hash, a stripped marker on a require_confine server, or a wiped store on a require_confine server; otherwise a **hybrid posture** — when no OS backend is present (Linux/CI/Windows) or the marker/profile is missing on a non-required server, it WARNs loudly and runs UNCONFINED (never silently). New `guard-events.jsonl` events (category `CONFINE`): `confine-applied`, `confine-hash-mismatch`, `confine-marker-stripped`, `confine-profile-missing`, `confine-backend-missing`, `confine-marker-malformed` — these are events, not OWASP signatures (the catalog count is unchanged at 9 entries over 8 targets). **Honest caveats:** macOS only (Linux `bwrap` and a strict tier deferred); the `sandbox-exec` path is not exercised in the ubuntu-only CI (mocked arg-vector unit tests + local darwin verification, the same gap the os-keychain shell-outs carry); confinement is opt-in (without it, enable/disable is unchanged); it does *not* stop network exfil in general (net is launcher-permissive), and does *not* protect against a same-user attacker who can rewrite both the IDE config and `~/.mcpm`. (#110)
- **`mcpm guard enable --confine` + `mcpm guard doctor-confine`** — the user-facing commands for F1. `guard enable --confine` (bare flag ⇒ "standard" tier; `--confine off` ⇒ disabled, same as omitting) enrolls every *unwrapped stdio* server it wraps into the sandbox, respecting `--server` / `--client` (url/HTTP and already-wrapped servers are not enrolled). `guard doctor-confine [--json]` is read-only: it reports OS-backend availability (platform + `/usr/bin/sandbox-exec` presence) and the enrolled servers (tier / net / require_confine), pointing to `guard status` for per-client wrap state. `guard disable` (existing) removes the wrap marker, which unconfines (a leftover profile in the store is harmless). **Deferred to a fast-follow:** the per-server `guard confine <server>` / `--off` / `--show` / `--require` / `--allow-read/-write/-net` command — per-server confine is achievable today via `enable --confine --server X` + `disable --server X`. (#111)

### Changed

- **`--orig-hash` is now verified at spawn time, not only on disable/unwrap (#108)** — the wrap marker's `--orig-hash` (the original server command binding) is checked when the relay spawns the child. **Phase 1 = WARN-once on mismatch** — it does *not* fail closed yet (a future release promotes it after zero-mismatch dogfood evidence); an *absent* hash (legacy pre-#29 wrap) is skipped, not failed. Emits an `orig-hash-mismatch` event (category `RELAY`). (#108)

### Internal

- **`store-integrity.ts` extraction (refactor, #109)** — `fileSha` / `assertNotSymlink` / `writeFileAtomic` were extracted from `pins.ts` and `policy.ts` into one shared `src/guard/store-integrity.ts` (the confine store reuses it). Behavior is identical; the symlink-refusal message now names the store (`pins` / `policy` / `confine`). (#109)

## [0.15.0] - 2026-06-22

A developer-experience release: `mcpm sync --check` surfaces cross-client config drift, plus a dependency-hygiene sweep.
Expand Down
27 changes: 23 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# MCP Registry — Project Context

> This file is automatically read by Claude Code on every session.
> Keep it updated as decisions are made. Last updated: 2026-06-20.
> Keep it updated as decisions are made. Last updated: 2026-07-02.

---

Expand All @@ -12,7 +12,7 @@ An open-source, CLI-first MCP package manager — **"npm for MCP servers"**.
A registry where developers can search, install, audit, publish, and update MCP servers
across all major clients (Claude Desktop, Cursor, VS Code, Windsurf) from a single tool.

**npm package**: `@getmcpm/cli` (v0.14.0) | **bin command**: `mcpm` | **repo**: github.com/getmcpm/cli | **web UI**: deferred to V1+
**npm package**: `@getmcpm/cli` (v0.15.0; the `guard --confine` arc is unreleased — ships next minor) | **bin command**: `mcpm` | **repo**: github.com/getmcpm/cli | **web UI**: deferred to V1+

---

Expand Down Expand Up @@ -134,7 +134,8 @@ Build the **open-source, community-owned npm+npm_audit** for MCP:
`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 secrets`, `mcpm why`
`mcpm guard doctor-confine`, `mcpm secrets`, `mcpm why`
(`mcpm guard enable --confine` opt-in wraps unwrapped stdio servers in an OS sandbox — macOS-first)

### Registry API (upstream, not ours)

Expand Down Expand Up @@ -285,7 +286,18 @@ Five `docs/ROADMAP.md` features (see its delivery log) + a full dogfood and CI g
- [x] **Dogfood + prevention guards (v0.12.1)** — a 6-cluster full-surface dogfood (102 cmds, 0 crashes) fixed 4 false-success-overclaim / mislabel bugs (`guard reset-integrity`/`accept-drift`, `secrets rm`, `search` "Trust Score"→"Status", stale `init`-pack completions). Then two CI guards so those classes can't recur: a completions↔Commander-program invariant test + a built-binary output-contract smoke matrix. (#92, #94)
- [x] **F3 — `up --frozen` fail-closed integrity BLOCK tier (v0.13.0)** — promotes the H11 WARN tripwire to an enforcing CI gate: pre-install verify of every locked npm server's `dist.integrity`, BLOCK (install nothing, exit nonzero — `npm ci` semantics) on drift / could-not-verify / format-mismatch / suspicious-missing-baseline; benign refuse-to-run for a pre-baseline lock; pypi/oci/url coverage notice. `--frozen` / `policy.frozen`. (#95)
- [x] **F5 — reject exfil-named tool-schema params, DENY-tier list-time (v0.14.0)** — structural `exfil-param-in-schema` detector walks `tools/list` inputSchema property KEYS and blocks a tool declaring an underscore-wrapped context-exfil sigil (`_system_prompt_`, …) before the model sees it; zero-FP deny tier (wrapped form only; `_context_`/`_memory_` framework slots excluded), honest "tripwire not defense" scope, muteable. (#97)
- [ ] **Next up (docs/ROADMAP.md):** F7 (`mcpm sync --check` cross-client drift) · F10 (response-side credential DLP) · the v1.0 bets — F1 `guard --confine` OS-sandbox, F8 `mcpm verify` Sigstore provenance, F9 doctor secret-scan/PATH. Known pre-existing nit: `hidden-chars-in-metadata` is un-muteable (not in the catalog) — the F5 empty-`patterns` fix would clear it.
- [ ] **Next up (docs/ROADMAP.md):** F10 (response-side credential DLP) · the v1.0 bets — F8 `mcpm verify` Sigstore provenance, F9 doctor secret-scan/PATH. (F7 `mcpm sync --check` shipped in v0.15.0; F1 `guard --confine` shipped to main — see the block below.)

### F1 `guard --confine` — first enforcement primitive (SHIPPED to main; UNRELEASED — next minor)

The first **enforcement** primitive in mcpm-guard — every prior guard feature is DETECTION (reasons about JSON-RPC bytes, warns/blocks). The relay is a stdio MITM that can *watch* every frame but cannot *contain* the child server it spawns; `--confine` wraps the relayed child in an OS sandbox so it physically cannot read secret files or persist, regardless of the JSON-RPC it emits. **macOS-only in v1** (Linux bwrap + a STRICT tier deferred). Built as a four-PR engine→enable-path arc, each ground→critique→TDD→review:
- [x] **#108 — orig-hash spawn-verify** — the wrap marker's `--orig-hash` is now verified at spawn (was disable/unwrap only); PHASE-1 WARN-once on mismatch (does NOT fail closed yet — a future release promotes it after zero-mismatch dogfood); an absent hash (legacy pre-#29 wrap) is skipped.
- [x] **#109 — store-integrity extraction** — `fileSha` / `assertNotSymlink` / `writeFileAtomic` factored out of `pins.ts` + `policy.ts` into shared `src/guard/store-integrity.ts` (the confine store reuses it); behavior identical, symlink-refusal message now names the store.
- [x] **#110 — confine core** — the sandbox-profile renderer + STANDARD tier + `~/.mcpm/guard-confine.yaml` store (+ `.integrity` sidecar, fails closed on integrity/shape/format-version mismatch like `pins.json`) + spawn-time decision in run-inner + `CONFINE`-category events (`confine-applied` / `-hash-mismatch` / `-marker-stripped` / `-profile-missing` / `-backend-missing` / `-marker-malformed`). These are EVENTS, not signatures — the catalog count is unchanged (still 9 entries over 8 targets).
- [x] **#111 — user-facing commands** — `guard enable --confine` (bare flag ⇒ "standard" tier; `--confine off` ⇒ disabled; enrolls every UNWRAPPED STDIO server it wraps; respects `--server`/`--client`; url/HTTP + already-wrapped servers not enrolled) + `guard doctor-confine [--json]` (READ-ONLY: OS-backend availability + enrolled servers). `guard disable` unconfines (leftover profile harmless).
- STANDARD tier (macOS Seatbelt/sandbox-exec): READ allow-all EXCEPT a secret-dir denylist (~/.ssh, ~/.aws, ~/.gnupg, gh/gcloud config, ~/.npmrc, ~/.docker, ~/.kube, ~/.netrc, ~/.git-credentials, cargo/pypi creds, Keychains, browser cookie stores, MCP client config dirs, mcpm's own ~/.mcpm); WRITE deny ALL of $HOME except caches (~/.npm, ~/.cache, ~/Library/Caches), the per-server scratch dir, system temp, and /dev (one rule blocks the whole persistence class — ~/.zshrc, LaunchAgents, PATH-shadowing ~/bin, git hooks); NET launcher-classified (npx/uvx/pip/pipx/docker/npm/pnpm/yarn/bun ⇒ "all", everything else ⇒ egress-deny). Per-server scratch (~/.mcpm/sandbox/<server>) is read+write.
- HYBRID POSTURE (fail-open default): CONFINE when enrolled + hash matches + backend available; FAIL CLOSED (refuse to start, exit 1) on tamper (hash mismatch / malformed hash) or a stripped marker / wiped store on a `require_confine` server; otherwise (no OS backend on Linux/CI/Windows, or marker/profile missing on a NON-required server) → WARN loudly + run UNCONFINED (never silently).
- Honest caveats (do not overclaim): macOS-only; the sandbox-exec path is NOT exercised in ubuntu-only CI (mocked arg-vector unit tests + local darwin verification — same gap as the os-keychain shell-outs); opt-in (enable/disable unchanged without it); does NOT stop network exfil in general (net is launcher-permissive) nor a same-user attacker who rewrites BOTH the IDE config AND ~/.mcpm. **Deferred fast-follow:** the per-server `guard confine <server>` / `--off` / `--show` / `--require` / `--allow-read/-write/-net` command (achievable today via `enable --confine --server X` + `disable --server X`).

### V1.5 (community trust)

Expand Down Expand Up @@ -476,6 +488,13 @@ the same entry shape).
| 2026-06-03 | Secret store gains real exfiltration resistance via an OS-keychain master key — SUPERSEDES 2026-06-01 deferral | Security issue #15. A random 32-byte master key is held in the OS credential store via **zero-native-dep shell-outs** (macOS `security`, Linux `secret-tool`/libsecret, Windows DPAPI-blob via PowerShell — no keytar, constraint preserved); per-value AES-GCM keys are derived from it with HKDF. Because the master key never lands in `~/.mcpm`, a copied `secrets.enc.json` cannot be decrypted off-machine/-account. New entries are tagged `k1:`; legacy machine-scheme entries stay decryptable and `mcpm secrets migrate` upgrades them. Where no OS keychain exists (headless/CI, or `MCPM_DISABLE_OS_KEYCHAIN=1`) it falls back to the honestly-labelled machine key. `secrets set` now reports which backend actually protected the value. Tests force the fallback via `MCPM_DISABLE_OS_KEYCHAIN=1` (vitest.setup.ts) so the suite never touches a real keychain; `os-keychain.ts` dispatch is unit-tested with mocked `spawn`. CI is ubuntu-only, so the macOS/Windows shell-outs are not exercised in CI — verified locally on darwin. Known tradeoff (security review, MEDIUM): macOS passes the master key in `security` argv (the binary has no reliable non-interactive stdin path), briefly visible to a same-user `ps` during the write; bounded (write-only window; a same-user attacker can already read process memory; read path uses stdout). Linux passes via stdin, Windows via env var. Documented in `os-keychain.ts`. |
| 2026-06-15 | Guard hardening program H1–H12 first slices SHIPPED in v0.10.0; docs reconciled to shipped state | The `docs/SECURITY-HARDENING.md` plan (drafted 2026-06-12 as "proposed") was executed: H1/H2 (#74), H9 (#76), H4 (#77), H7-A (#78), H5 (#79) + H11 supply-chain integrity tripwire slice-1 (#81), all released in **v0.10.0**; v0.10.1 docs patch (#85/#86). H3/H6/H8/H10/H12 deferred with documented reasons. The **Delivery status** table in SECURITY-HARDENING.md is now the per-control source of truth; per-PR design decisions live in commit history. This doc/ROADMAP/README reconcile pass corrected the prior `proposed / v0.8.1 / "nothing built yet"` drift. |
| 2026-06-19 | F2 cross-server tool-shadowing shipped as WARN-tier name-collision slice, NOT the ROADMAP's "HIGH-block" | A grounded design+critique pass overrode the ROADMAP F2 spec on two points: (1) **WARN/advisory, not HIGH-block** — legit stacks routinely share tool names (two filesystem servers → `read_file`; same package under two names; generic verbs), so an unconditional block would drive users to disable the check (H12 consent-fatigue); `--ci` is the only blocking mode. (2) **Best-effort over already-guarded servers, exact-name only** — the inventory source is `pins.json`, which the *relay* TOFU-populates, so a never-guarded server contributes no names: v1 is a stack-hygiene / re-audit aid, **not** a fresh-install control, and it does NOT catch the description-reference confused-deputy (the Problem example, different tool names) nor a homoglyph evasion. The code surfaces the blind spot loudly (a coverage line). `origin-index.json` persistence + the text-reference heuristic + relay-time integration are the deferred fast-follow. `mcpm up --check-shadowing` / `policy.checkShadowing`; pure detector in `src/guard/shadow.ts`, zero new deps. |
| 2026-07-02 | F1 `guard --confine` built as a 4-PR engine→enable-path arc (#108→#109→#110→#111) | Confinement is the FIRST enforcement primitive — every prior guard feature was detection. Sequenced so each PR lands green independently: spawn-verify seam (#108), shared store-integrity extraction (#109), confine core engine + store + spawn-time decision (#110), then the user-facing `enable --confine` / `doctor-confine` commands (#111). SHIPPED to main, UNRELEASED — ships next minor (current released v0.15.0). |
| 2026-07-02 | Confine is macOS-first with a HYBRID posture (fail-open default, per-server `require_confine` fail-closed) | The relay MITM can watch frames but cannot contain the child it spawns; only an OS sandbox can. macOS Seatbelt/sandbox-exec ships in v1; Linux bwrap + a STRICT tier deferred (value/effort). Default posture is fail-OPEN: where no OS backend exists (Linux/CI/Windows) or the marker/profile is missing on a non-required server → WARN loudly + run UNCONFINED, never silently. A server marked `require_confine` flips to fail-CLOSED (refuse to start, exit 1) on a stripped marker / wiped store — the escape hatch for stacks that must never run unsandboxed. |
| 2026-07-02 | Confine marker = content-hash + `--confine-required` replicated into IDE config, both neutral to `--orig-hash` | The wrap entry carries `--confine-profile-hash <sha256>` (a CONTENT hash of the rendered profile, binding marker↔stored-profile so tamper is detectable) and a bare `--confine-required` flag (present iff `require_confine`, replicated into the IDE config so the fail-closed posture SURVIVES a wiped ~/.mcpm store). Both sit before `--` and are EXCLUDED from the `--orig-hash` input (position unchanged), so unwrap/disable still work unmodified. The store `~/.mcpm/guard-confine.yaml` remains the source of truth for enrollment. |
| 2026-07-02 | STANDARD tier = read-denylist + write-allowlist (deny $HOME except caches) + launcher-classified net | Chosen over a strict read-allowlist/scratch-only-write because it protects the actual threat (secret-file read, persistence) without breaking the long tail of legitimate reads. WRITE is one deny-$HOME-except-caches rule that blocks the whole persistence class (~/.zshrc, LaunchAgents, PATH-shadowing ~/bin, git hooks) rather than enumerating targets. NET is launcher-classified: fetch-at-launch runners (npx/uvx/pip/pipx/docker/npm/pnpm/yarn/bun) get network "all", everything else egress-deny — do NOT claim general exfil protection (net is launcher-permissive). Strict tier (read-allowlist / scratch-only-write / host-granular net) deferred. |
| 2026-07-02 | `--orig-hash` now verified at SPAWN too, but Phase-1 warn-once (not yet fail-closed) | #108 extends orig-hash verification (previously disable/unwrap only) to spawn time. Deliberately WARN-once on mismatch rather than fail-closed — a future release promotes it after zero-mismatch dogfood evidence, same evidence-before-enforcement discipline as the H11→F3 tripwire promotion. An ABSENT hash (legacy pre-#29 wrap) is skipped, not failed. |
| 2026-07-02 | store-integrity helpers extracted to `src/guard/store-integrity.ts` (#109) | `fileSha` / `assertNotSymlink` / `writeFileAtomic` were duplicated in `pins.ts` + `policy.ts`; the confine store needed the same primitives. Extracted to one shared module, behavior identical; the symlink-refusal message now names which store ("pins"/"policy"/"confine"). Same integrity-not-authenticity honesty as pins/policy (issue #19): the unkeyed sidecar is tamper-EVIDENCE, not authenticity vs a same-user attacker. |
| 2026-07-02 | Corrected the ROADMAP F1 premise — spawn-verify had to be BUILT, it did not already exist | The `docs/ROADMAP.md` F1 entry assumed confine could "ride the existing `--orig-hash` spawn verify." That premise was FALSE: orig-hash was verified only on disable/unwrap, never at spawn — so #108 had to build the spawn-verify seam first. This is why F1 was an L→XL 4-PR arc, not the small rider the ROADMAP implied. |

---

Expand Down
Loading