diff --git a/CHANGELOG.md b/CHANGELOG.md index 059bf2a..178f8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` (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 ` / `--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. diff --git a/CLAUDE.md b/CLAUDE.md index 389669f..fccb42d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. --- @@ -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+ --- @@ -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) @@ -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/) 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 ` / `--off` / `--show` / `--require` / `--allow-read/-write/-net` command (achievable today via `enable --confine --server X` + `disable --server X`). ### V1.5 (community trust) @@ -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 ` (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. | --- diff --git a/README.md b/README.md index bacf5d9..0dc01b0 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,10 @@ The `demo` command boots an in-process synthetic malicious server that returns a Detection is regex + structural; NFKC + zero-width-char stripping defeats the common Unicode evasions, and a separate hidden-character *presence* check flags evasion carriers before they're normalized away. See `mcpm guard list-signatures` for the current shipped set. +### Confinement (opt-in enforcement) + +Everything above is *detection* — the relay reasons about the JSON-RPC bytes and warns or blocks. But a server that decides to read `~/.ssh` or write a `~/Library/LaunchAgents` persistence hook never expresses that through inspectable traffic. `mcpm guard enable --confine` complements detection with *enforcement*: it wraps each relayed stdio server in an OS sandbox that physically denies reads of a secret-file denylist and writes outside caches/scratch, so the server can't exfil credentials or persist regardless of the JSON-RPC it emits. `mcpm guard doctor-confine` reports backend availability and which servers are enrolled. **macOS only** for now (Seatbelt/`sandbox-exec`); on other platforms it warns and runs unconfined rather than giving a false sense of protection. See `docs/GUARD.md` for the tier details and caveats. + ### Day-1 commands ```bash @@ -262,6 +266,8 @@ mcpm guard disable [--client ] [--server ] # unwrap mcpm guard status # what's wrapped + pin state mcpm guard demo # synthetic attack-block demo mcpm guard list-signatures [--json] # show shipped signatures +mcpm guard enable --confine # also OS-sandbox wrapped stdio servers (macOS) +mcpm guard doctor-confine [--json] # confine backend availability + enrolled servers ``` ### When a block fires diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 34c1d48..a4158eb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -77,7 +77,15 @@ mcpm/ │ │ ├── run-inner.ts — `mcpm guard run --inner` entry, wires the relay │ │ ├── event-log.ts — append-only JSONL writer for guard-events.jsonl │ │ ├── sanitize.ts — shared ANSI/control-char terminal sanitizer +│ │ ├── store-integrity.ts — shared fileSha / assertNotSymlink / writeFileAtomic (pins + policy + confine) │ │ ├── cli.ts — Commander glue for enable/disable/status/cleanup +│ │ ├── confine/ — OS confinement (F1, macOS-only, opt-in via --confine) +│ │ │ ├── profile.ts — standard-tier read/write/net rule set +│ │ │ ├── derive.ts — render a Seatbelt profile for a server (+ content hash) +│ │ │ ├── backend-macos.ts — sandbox-exec backend + availability pre-check +│ │ │ ├── apply.ts — wrap the child spawn argv with the backend +│ │ │ ├── store.ts — ~/.mcpm/guard-confine.yaml enrollment store (+ integrity) +│ │ │ └── decide.ts — spawn-time confine/fail-closed/hybrid-warn decision │ │ └── demo/ — synthetic echo-bot + runner for `mcpm guard demo` │ └── utils/ │ ├── output.ts — leveled output helpers @@ -104,10 +112,10 @@ mcpm/ | Module | Purpose | |---|---| -| `commands/` | 24 CLI commands (incl. `guard` subcommand group with 12 subcommands and `publish` with 3 subcommands), each a self-contained Commander action | +| `commands/` | 24 CLI commands (incl. `guard` subcommand group with 13 subcommands and `publish` with 3 subcommands), each a self-contained Commander action | | `server/` | MCP server (stdio): 9 tools wrapping CLI logic via injectable handlers | | `stack/` | Stack file schemas (mcpm.yaml/mcpm-lock.yaml), semver resolution, trust policy, .env parsing | -| `guard/` | **v0.5.0 runtime defense.** Stdio MITM relay, OWASP MCP Top 10 pattern engine, schema pinning + drift detection, policy file editor, integrity sidecars, event log. See `docs/GUARD.md`. | +| `guard/` | **v0.5.0 runtime defense.** Stdio MITM relay, OWASP MCP Top 10 pattern engine, schema pinning + drift detection, policy file editor, integrity sidecars, event log. Plus `guard/confine/` (F1, unreleased/next minor): the first **enforcement** primitive — the relay optionally wraps the child spawn in an OS sandbox (macOS `sandbox-exec`) so a server physically can't read secret files or persist, complementing byte-level detection. See `docs/GUARD.md`. | | `registry/` | Typed HTTP client for the official MCP Registry API (v0.1 at registry.modelcontextprotocol.io) | | `config/` | OS-aware config paths, client detection, and per-client config adapters with atomic writes | | `scanner/` | Trust scoring engine: tier 1 (metadata), tier 2 (static pattern analysis), composite score | @@ -142,6 +150,8 @@ mcpm/ | `mcpm secrets` | Manage encrypted credentials for MCP servers | | `mcpm publish` | Publish an MCP server to the official registry | | `mcpm guard enable / disable / status` | Wrap detected client configs with the inspection relay; restore; report state | +| `mcpm guard enable --confine` | Opt-in: also enroll unwrapped stdio servers into an OS sandbox (macOS-only; `--confine off` disables) | +| `mcpm guard doctor-confine` | Read-only: report OS-backend availability + enrolled servers (tier / net / require_confine) | | `mcpm guard demo` | Synthetic prompt-injection scenario (visible block in terminal) | | `mcpm guard accept-drift / mute / unmute / pause / cleanup` | Runtime tuning + escape hatches | | `mcpm guard list-signatures / reset-integrity` | Catalog inspection + integrity sidecar regeneration | @@ -280,6 +290,60 @@ end Note over IDE,EventLog: Event log entry (if findings):
{ts, server_name, direction,
action, findings:[{signature_id,
category, severity, target,
matched_text_excerpt, remediation}]} ``` +### OS confinement (F1 — unreleased / next minor) + +Everything above is **detection**: the relay reasons about JSON-RPC bytes and warns +or blocks. It cannot *contain* the child it spawns — a server that decides to read +`~/.ssh` or write `~/Library/LaunchAgents` never expresses that intent through +inspectable traffic. `--confine` is the first **enforcement** primitive: it wraps the +relayed child in an OS sandbox so it physically cannot read secret files or persist, +regardless of the JSON-RPC it emits. Detection (watch) and confinement (contain) are +complementary. **macOS-only in v1** (Linux `bwrap` deferred); **opt-in** — without +`--confine`, enable/disable behavior is unchanged. + +``` + mcpm guard enable --confine + │ enrolls each UNWRAPPED stdio server it wraps + ▼ + ~/.mcpm/guard-confine.yaml (+ .integrity) ← source of truth for "server X is confined" + │ + │ wrap marker gains two neutral tokens before `--`: + │ --confine-profile-hash (binds marker ↔ stored profile) + │ --confine-required (bare flag, replicated into IDE config) + ▼ + mcpm guard run --inner (spawn-time decision, src/guard/confine/decide.ts) + │ + ├── CONFINE — enrolled + hash matches + backend available + │ └─ apply.ts wraps the child argv with backend-macos.ts (sandbox-exec) + │ using the derived Seatbelt profile (derive.ts + profile.ts) + │ + ├── FAIL CLOSED (refuse to start, exit 1) — hash mismatch / malformed hash / + │ stripped marker or wiped store on a require_confine server + │ + └── HYBRID (WARN loudly + run UNCONFINED, never silent) — no OS backend + (Linux/CI/Windows) or missing marker/profile on a NON-required server +``` + +Standard tier (macOS Seatbelt): **read** allow-all *except* a secret-dir denylist +(`~/.ssh`, `~/.aws`, `~/.gnupg`, cloud/gh/npm/docker/kube creds, keychains, browser +cookie stores, MCP client config dirs, and mcpm's own `~/.mcpm`); **write** deny all of +`$HOME` *except* caches, the per-server scratch dir, and system temp/`/dev` — one rule +that blocks the whole persistence class (`~/.zshrc`, LaunchAgents, PATH-shadowing +`~/bin`, git hooks); **net** launcher-classified (npx/uvx/pip/docker/… ⇒ network "all", +everything else ⇒ egress-deny). The confine store reuses the shared +`store-integrity.ts` extracted from `pins.ts` + `policy.ts` and fails closed on +integrity/shape/format-version mismatch. CONFINE events (`confine-applied`, +`confine-hash-mismatch`, `confine-marker-stripped`, …) append to +`guard-events.jsonl`; the OWASP signature catalog count is unchanged. + +Honest caveats: the sandbox-exec path is not exercised in ubuntu-only CI (mocked +arg-vector unit tests + local darwin verification, same gap the os-keychain shell-outs +carry); net is launcher-permissive (do not read this as general exfil prevention); it +does not defend against a same-user attacker who can rewrite **both** the IDE config +and `~/.mcpm`. A strict tier (read-allowlist / scratch-only-write / host-granular net), +Linux `bwrap`, and the per-server `guard confine ` command are deferred (per-server +confine is achievable today via `enable --confine --server X` + `disable --server X`). + ## Configuration ### Local state directory @@ -293,6 +357,9 @@ Note over IDE,EventLog: Event log entry (if findings):
{ts, server_name, dir ├── pins.json.integrity — sha256 sidecar over pins.json ├── guard-policy.yaml — user overrides (mute/pause) ├── guard-policy.yaml.integrity — sha256 sidecar over guard-policy.yaml +├── guard-confine.yaml — confine enrollment store (F1; source of truth for "server X is confined") +├── guard-confine.yaml.integrity — sha256 sidecar over guard-confine.yaml (fails closed on mismatch) +├── sandbox// — per-server confine scratch dir (read+write inside the sandbox) └── guard-events.jsonl — append-only event log (parse with jq) ``` diff --git a/docs/GUARD.md b/docs/GUARD.md index 3d6f56e..8afc262 100644 --- a/docs/GUARD.md +++ b/docs/GUARD.md @@ -123,9 +123,17 @@ The wrap transformation in JSON: A pre-batch `.bak` snapshot is written per touched client (`.guard-enable.bak`) so the whole operation is recoverable even if a single per-server write fails mid-batch. +The `--orig-hash` token is now verified at spawn time too (previously it was checked only on `disable`/unwrap). This is **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 (a legacy pre-`--orig-hash` wrap) is skipped, not failed. + ### `mcpm guard disable [--client ] [--server ]` Reverses the wrap by parsing the wrap marker out of the args and reconstructing the original entry. Falls back to the `.bak` if the wrap pattern is malformed (e.g. user hand-edited the config since enable). +### `mcpm guard enable [...] --confine [off]` +Opt-in OS sandbox for the servers being wrapped (macOS only, unreleased — next minor). See **OS confinement (`--confine`)** below. The bare flag enrolls every unwrapped stdio server it wraps at the "standard" tier; `--confine off` is the same as omitting the flag (no enrollment). Respects `--client` / `--server`. + +### `mcpm guard doctor-confine [--json]` +Read-only. Reports OS-backend availability (platform + presence of `/usr/bin/sandbox-exec`) and the enrolled servers (tier / net / require_confine). Points to `mcpm guard status` for per-client wrap state. See **OS confinement (`--confine`)** below. + ### `mcpm guard status` Prints what's wrapped and the pin state per server (unprotected / first-session-pin pending / fully protected). @@ -174,6 +182,60 @@ The relay refuses to use either file if its sidecar doesn't match — this is th --- +## OS confinement (`--confine`) + +> Status: shipped to `main` across PRs #108–#111, **unreleased (next minor)**. **macOS only in v1.** Opt-in — without `--confine`, `enable`/`disable` behave exactly as before. + +Every guard feature above is **detection**: the relay reasons about the JSON-RPC bytes flowing through it and warns or 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 simply *decides* to read `~/.ssh` or write `~/Library/LaunchAgents` never expresses that intent through inspectable traffic. `--confine` is the first **enforcement** primitive: it wraps the relayed child in an OS sandbox so it physically cannot read secret files or persist, regardless of the JSON-RPC it emits. Detection and confinement are complementary — watch vs contain. + +### The standard tier + +On macOS, confinement is applied via Seatbelt (`sandbox-exec`). The one shipped tier is "standard": + +- **READ — allow-all except a secret-dir denylist.** Reads are permitted everywhere *except* a curated set of credential/config locations: SSH / AWS / gcloud / gh / GnuPG keys, `~/.npmrc` / `~/.docker` / `~/.kube` / `~/.netrc` / `~/.git-credentials` and similar credential files, macOS Keychains, browser cookie stores, the MCP client config dirs, and mcpm's own `~/.mcpm` store. +- **WRITE — deny-all-of-`$HOME` except caches + scratch.** A single rule blocks the whole persistence class (`~/.zshrc`, `~/Library/LaunchAgents`, PATH-shadowing `~/bin`, git hooks). Writes are allowed only to caches (`~/.npm`, `~/.cache`, `~/Library/Caches`), the per-server scratch dir, system temp (`/tmp`, `/private/tmp`, `/var/folders`, `/var/tmp`), and `/dev`. +- **NET — launcher-classified.** Launchers that fetch at startup (npx / uvx / pip / pipx / docker / npm / pnpm / yarn / bun) get network "all"; everything else gets egress-deny "none". +- The server's own scratch dir (`~/.mcpm/sandbox/`) is both readable and writable. + +### Enabling and disabling + +``` +mcpm guard enable --confine # enroll every wrapped stdio server, standard tier +mcpm guard enable --confine --server foo # scope to one server +mcpm guard enable --confine off # same as omitting: no enrollment +``` + +`--confine` enrolls only **unwrapped stdio** servers it wraps — url/HTTP servers and already-wrapped servers are skipped. To **unconfine**, run the existing `mcpm guard disable` (optionally `--server `): it removes the wrap marker, which removes confinement. A leftover profile left behind in the store is harmless. + +### Inspecting + +`mcpm guard doctor-confine [--json]` is read-only: it reports OS-backend availability (platform + `/usr/bin/sandbox-exec` presence) and the enrolled servers with their tier / net / require_confine flags. For per-client wrap state, use `mcpm guard status`. + +### Hybrid posture (what happens without a backend) + +Confinement never fails silently. The store (`~/.mcpm/guard-confine.yaml`) is the source of truth for "is server X enrolled". + +- **Confine** when the server is enrolled + the profile hash matches + an OS backend is available. +- **Run unconfined with a loud notice** when there is no OS backend (Linux / CI / Windows) or the marker/profile is missing on a **non-required** server. Never silent — backend availability is pre-checked, never inferred from a spawn error. +- **Fail closed (refuse to start, exit 1)** at spawn time on: a profile-hash mismatch (tamper), a malformed hash, a stripped marker on a `require_confine` server, or a wiped store on a `require_confine` server. + +### Marker tokens + +The wrapped config entry carries two new tokens **before** the `--` separator: `--confine-profile-hash ` (a content hash of the rendered profile, binding the marker to the stored profile) and `--confine-required` (a bare flag, present iff the server is `require_confine`, replicated into the IDE config so it survives a wiped store). Both are **neutral** to `--orig-hash` — excluded from its hash input, and the `--` position is unchanged — so unwrap/disable still work. + +### Store + +`~/.mcpm/guard-confine.yaml` (+ an unkeyed `.integrity` sidecar) is the enrollment source of truth. Like `pins.json`, it **fails closed** on integrity / shape / format-version mismatch. The sidecar is tamper-*evidence*, not authenticity (issue #19) — the same honest labeling as the pins and policy sidecars. + +### Caveats + +- **macOS only.** Linux `bwrap` support and a STRICT tier (read-allowlist / scratch-only-write / host-granular net) are deferred. +- The macOS `sandbox-exec` path is **not exercised in CI** (ubuntu-only); it is covered by mocked arg-vector unit tests plus local darwin verification — the same gap the os-keychain darwin shell-outs already carry. +- Net is launcher-permissive, so confinement does **not** stop network exfil in general, and it does **not** defend against a same-user attacker who can rewrite both the IDE config *and* `~/.mcpm`. +- **Deferred fast-follow:** the per-server `mcpm guard confine ` command (`--off` / `--show` / `--require` / `--allow-read/-write/-net`) is not shipped. Per-server confine is achievable today via `enable --confine --server X` + `disable --server X`. + +--- + ## Files mcpm-guard touches | Path | Purpose | Format | @@ -182,11 +244,15 @@ The relay refuses to use either file if its sidecar doesn't match — this is th | `~/.mcpm/pins.json.integrity` | SHA-256 of `pins.json` content | One-line sha256 sidecar | | `~/.mcpm/guard-policy.yaml` | User overrides + pause state | YAML, see `docs/POLICY.md` | | `~/.mcpm/guard-policy.yaml.integrity` | SHA-256 of `guard-policy.yaml` | One-line sha256 sidecar | +| `~/.mcpm/guard-confine.yaml` | OS-confinement enrollment (tier / net / require_confine per server) | YAML, fails closed like pins.json | +| `~/.mcpm/guard-confine.yaml.integrity` | SHA-256 of `guard-confine.yaml` | One-line sha256 sidecar | | `~/.mcpm/guard-events.jsonl` | Append-only event log | JSON-Lines | | `.guard-{enable,disable}.bak` | Pre-batch backup per touched client | Original JSON content | All files are written `0o600`; the parent dir is `0o700`. +Confinement adds events (not signatures — the catalog count is unchanged) to `guard-events.jsonl` under category `CONFINE`: `confine-applied`, `confine-hash-mismatch`, `confine-marker-stripped`, `confine-profile-missing`, `confine-backend-missing`, `confine-marker-malformed`; plus `orig-hash-mismatch` (category `RELAY`, from the spawn-time `--orig-hash` verify). + --- ## Day-1 vs Day-7 vs Day-30 surface diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fb70d59..8f62b72 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -33,7 +33,17 @@ > a structural property-KEY walker blocks a `tools/list` when a tool declares an underscore-wrapped > context-exfil sigil param like `_system_prompt_`; zero-FP deny tier, honest "tripwire not defense" > scope). The bare-name SUSPECT tier + description-cross-check remain deferred. -> - **Next up:** F7 (`sync --check`) · F10 (response DLP) · F8/F9/F1 (v1.0 bets). +> - ✅ **F1 — `guard --confine` OS-native sandbox (standard tier)** — engine + enable-path shipped +> across PRs #108/#109/#110 (merged) + #111 (user-facing commands); **unreleased (next minor)**. +> macOS Seatbelt standard tier, wrap-marker tokens (`--confine-profile-hash` / `--confine-required`), +> a 9-row spawn-decision table, the `~/.mcpm/guard-confine.yaml` store, and `guard enable --confine` +> + `guard doctor-confine`. **Load-bearing correction:** the plan below assumed confine could "ride the +> existing `--orig-hash` spawn-verify" as one call-site change — that spawn-verify **did not exist** +> (`--orig-hash` was verified only on disable/unwrap), so orig-hash spawn-verify (WARN-once, Phase 1) +> was built from scratch (#108) and effort was **XL, not L**. Deferred: Linux bwrap, the strict tier, +> orig-hash Phase-2 fail-closed, and the per-server `guard confine ` command (achievable today +> via `enable --confine --server X` + `disable --server X`). +> - **Next up:** F10 (response DLP) · F8/F9 (v1.0 bets). > > This roadmap was produced by a grounded research-and-planning pass: six parallel > web-research lenses (threat landscape, competitors, MCP protocol evolution, DevX, @@ -70,7 +80,7 @@ Scoring: `composite = impact + differentiation + alignment + 0.5·effort_cheapne | # | Feature | Cat | Effort | Score | Bucket | |---|---|---|---|---|---| -| 1 | `guard --confine` — OS-native sandbox (standard tier) | sec | L | **16.5** | v1.0 major | +| 1 | `guard --confine` — OS-native sandbox (standard tier) | sec | ~~L~~ **XL** | **16.5** | ✅ shipped (engine + enable-path; unreleased, next minor); Linux/strict/per-server-CLI deferred | | 2 | Cross-server tool-shadowing detection (name-collision v1) | sec | S→M | **16.0** | v0.9 minor | | 3 | Content-pinned lockfile (digest tier) + `up --frozen` | both | M | **15.5** | ◑ digest WARN (H11 #81) + `--frozen` BLOCK shipped; multi-registry + registry-claim re-proof deferred | | 4 | Release-age cooldown + install-script-shape awareness ✅ **shipped (PR #70)** | sec | S→M | **15.5** | v0.9 minor | @@ -233,8 +243,43 @@ Sequenced to keep momentum and the Dependabot surface clean (the v0.9 set adds * # v1.0 — next major (the flagship + harder bets) -## F1 · `guard --confine` — OS-native sandbox (standard tier) — **THE STRATEGIC BET** -**Category:** security · **Effort:** L (standard) · **Score 16.5** (highest) +## F1 · `guard --confine` — OS-native sandbox (standard tier) — **THE STRATEGIC BET** ✅ **shipped (engine + enable-path)** +**Category:** security · **Effort:** ~~L (standard)~~ **XL** · **Score 16.5** (highest) + +> **Shipped (engine + enable-path)** across PRs #108/#109/#110 (merged) + #111 (user-facing commands); +> **unreleased (next minor)**. mcpm-guard's **first enforcement primitive** — every prior guard feature +> is *detection* (reasons about JSON-RPC bytes, warns/blocks); a stdio MITM cannot *contain* a child that +> *decides* to read `~/.ssh` or persist a LaunchAgent. `--confine` wraps the relayed child in an OS +> sandbox so it physically cannot, regardless of the JSON-RPC it emits. **macOS only in v1.** +> - **Load-bearing correction to the plan below:** the "single load-bearing insertion" premise — +> that confine rides the existing `--orig-hash` spawn-verify as *one call-site change* — was **FALSE**. +> No spawn-time verify existed: `--orig-hash` was verified only on disable/unwrap. So orig-hash +> spawn-verify was **built from scratch** (#108, WARN-once Phase 1 — does not yet fail closed; an absent +> legacy hash is skipped, not failed), and the real effort was **XL, not L**. +> - **What shipped:** macOS Seatbelt/`sandbox-exec` **standard tier** (read allow-all EXCEPT a secret-dir +> denylist — `~/.ssh`/`~/.aws`/`~/.gnupg`/`~/.config/gh`/keychains/browser-cookie stores/MCP-client +> config dirs/mcpm's own store; write-deny all of `$HOME` except caches + the per-server scratch dir + +> system temp + `/dev`; net launcher-classified — `npx`/`uvx`/`pip`/`docker`/… ⇒ network, everything +> else ⇒ egress-deny); two new **wrap-marker tokens** (`--confine-profile-hash ` binding +> marker↔stored-profile, and a bare `--confine-required`, both `--orig-hash`-neutral); a **9-row +> spawn-decision table** in `run-inner` (confine when enrolled + hash matches + backend available; +> FAIL CLOSED on hash-mismatch / malformed-hash / stripped-marker-on-required / wiped-store-on-required; +> HYBRID WARN-loud-and-run-unconfined when no backend or a missing marker on a non-required server — +> never silent); the `~/.mcpm/guard-confine.yaml` store (+ `.integrity` sidecar; fails closed on +> integrity/shape/format-version mismatch like `pins.json`; unkeyed sidecar = tamper-evidence NOT +> authenticity, issue #19); shared `src/guard/store-integrity.ts` extracted from `pins.ts`/`policy.ts` +> (#109); and the user-facing `guard enable --confine` (bare flag ⇒ standard tier; `--confine off` ⇒ +> disabled; enrolls unwrapped stdio servers it wraps) + read-only `guard doctor-confine [--json]`. +> New `guard-events.jsonl` events under category `CONFINE` (plus `orig-hash-mismatch` under RELAY) — +> these are events, not signatures, so the catalog count is unchanged. +> - **Deferred:** Linux `bwrap`, the **strict tier** (below), orig-hash **Phase-2 fail-closed**, and the +> per-server `guard confine ` / `--off` / `--show` / `--require` / `--allow-read/-write/-net` +> command (per-server confine is achievable today via `enable --confine --server X` + `disable +> --server X`). **Honest caveats:** the macOS `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); +> confine is opt-in (without it enable/disable is unchanged); net is launcher-permissive so this does +> NOT stop network exfil in general; and it does NOT defend a same-user attacker who can rewrite BOTH +> the IDE config AND `~/.mcpm`. **Problem.** The relay is a stdio MITM: it inspects every JSON-RPC frame but does **not contain** the child spawned at `relay.ts:130` — it inherits the IDE's full privileges and can `open("~/.ssh/id_ed25519")`, `connect()` anywhere, or fork a curl-exfil, and the relay sees only the JSON-RPC it chooses to send. This is the structural half a byte-inspecting relay **categorically cannot reach**. diff --git a/docs/SECURITY-HARDENING.md b/docs/SECURITY-HARDENING.md index d529508..9447807 100644 --- a/docs/SECURITY-HARDENING.md +++ b/docs/SECURITY-HARDENING.md @@ -45,6 +45,13 @@ H7 now also blocks **credential-phishing** elicitation/sampling prompts (ROADMAP (quota / consent-gating / hard credential-field block) and H11's remaining scope (multi-registry, digest **BLOCK** / `--frozen`, Sigstore provenance) stay deferred — see §6 and ROADMAP F3/F8. +**Runtime containment now has an implementation.** Every H1–H12 control here is *detection* +(reasoning about JSON-RPC bytes); ROADMAP **F1 `guard --confine`** (a separate track — see +`docs/ROADMAP.md`) adds the first *enforcement* primitive by wrapping the relayed stdio child in an +OS sandbox (macOS Seatbelt/`sandbox-exec` in v1) so it physically cannot read secret files or +persist, regardless of the traffic it emits — complementary to frame detection (contain vs watch). +Shipped across #108/#109/#110/#111, macOS-only, opt-in, **unreleased (next minor)**. + --- ## 0. The six principles (these govern every control below) diff --git a/docs/SIGNATURES.md b/docs/SIGNATURES.md index 42e7f3a..27d48c6 100644 --- a/docs/SIGNATURES.md +++ b/docs/SIGNATURES.md @@ -24,6 +24,8 @@ Plus the `hidden-chars-in-metadata` presence detector (category OWASP-MCP-1, tar Plus the runtime drift detectors (`schema-drift`, `schema-drift-cosmetic`, `schema-drift-in-session`) — emitted by the relay, not by the signature engine. Drift is classified per changed field (H4): a **description-only** change is `schema-drift-cosmetic` (severity high → warn, forwarded — the parallel `tool_description` pattern scan still blocks any regex-detectable injection on the same frame, since the relay takes the MAX action); a **schema or annotations** change — or any pre-H4 pin with no stored field hashes — is `schema-drift` (critical → block). A server→client `notifications/tools/list_changed` arms a single-shot re-validation so an *announced* upgrade is classified against the pin rather than tripping the same-session guard. Cosmetic warn is bounded by the pattern-engine regex floor (a paraphrased poison the regexes miss degrades to a forwarded warn — the opt-in LLM-judge tier is the V2 answer, not the drift tier). +> **Not signatures: confine + orig-hash spawn events.** The `--confine` OS-sandbox primitive (F1, unreleased — next minor) adds **no OWASP signatures** — the catalog above is unchanged (still 9 entries over 8 targets). It emits relay/spawn **events** (not detection signatures) to `guard-events.jsonl`: category `CONFINE` — `confine-applied`, `confine-hash-mismatch`, `confine-marker-stripped`, `confine-profile-missing`, `confine-backend-missing`, `confine-marker-malformed`; plus category `RELAY` — `orig-hash-mismatch` (the wrap marker's `--orig-hash` verified at spawn, #108). These reason about spawn-time enrollment/integrity, not JSON-RPC frame content, so they are outside the signature engine. + ## Action mapping - **critical → block** by default diff --git a/package.json b/package.json index 405ce60..9d0b0a6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "lint": "tsc --noEmit", "typecheck": "tsc --noEmit", "clean": "rm -rf dist coverage", + "dogfood:confine": "pnpm build && bash scripts/dogfood-confine.sh", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/scripts/dogfood-confine.sh b/scripts/dogfood-confine.sh new file mode 100755 index 0000000..a7a2c74 --- /dev/null +++ b/scripts/dogfood-confine.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# Hermetic end-to-end dogfood for `mcpm guard --confine` (macOS Seatbelt enforcement). +# +# WHY a throwaway HOME: every mcpm path derives from os.homedir(), which respects +# $HOME on POSIX. Pointing HOME at a mktemp dir gives REAL kernel `sandbox-exec` +# enforcement with zero blast radius — it never touches your real ~/.mcpm, IDE +# configs, or secrets. sandbox-exec is a kernel facility, so your Mac IS the +# accurate env; filesystem isolation (not a different machine) is what makes the +# result trustworthy and reproducible. +# +# WHY it's trustworthy: a POSITIVE CONTROL reads the same decoy secret unconfined +# (must succeed) before the confined child tries (must be denied with EPERM/EACCES, +# not ENOENT). Same path, same code — unconfined success + confined denial proves +# the sandbox is the cause, not a missing file. +# +# This exercises the whole shipped chain CI cannot: enable --confine → marker + +# store → guard run spawn → decide table → sandbox-exec → relay stdio. ubuntu CI +# only runs the mocked arg-vector unit tests. Run this before every release. +# +# macOS only (sandbox-exec). Exits 0 (SKIP) on other platforms. + +set -euo pipefail + +step() { printf "\n\033[1;36m▶ %s\033[0m\n" "$1"; } + +if [[ "$(uname)" != "Darwin" ]]; then + echo "SKIP: dogfood-confine requires macOS (sandbox-exec)." >&2 + exit 0 +fi + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +export DIST="$REPO/dist/index.js" +if [[ ! -f "$DIST" ]]; then + echo "FAIL: $DIST not found — run 'pnpm build' first (or use 'pnpm dogfood:confine')." >&2 + exit 1 +fi + +# --- hermetic sandbox home -------------------------------------------------- +# Canonicalize (pwd -P): macOS `mktemp -d` lands under /var/folders, and /var is +# a symlink to /private/var. Seatbelt (subpath ...) matches the CANONICAL path, +# so a deny rule derived from a /var/folders home would silently not match a +# /private/var read — a false pass. A real user's $HOME (/Users/) is +# already canonical; resolving symlinks here makes the hermetic home faithful. +DFHOME="$(cd "$(mktemp -d)" && pwd -P)" +cleanup() { rm -rf "$DFHOME"; } +trap cleanup EXIT +export HOME="$DFHOME" +unset MCPM_DISABLE_CONFINE # the backend MUST be on + +step "Hermetic HOME: $HOME" + +# --- seed a decoy secret in a denylisted dir (~/.ssh) ----------------------- +mkdir -p "$HOME/.ssh" +printf 'TOPSECRET-do-not-read\n' > "$HOME/.ssh/decoy" + +# --- tiny stdio MCP server; reads argv[2] and reports if it was denied ------ +mkdir -p "$HOME/srv" +cat > "$HOME/srv/server.js" <<'EOF' +// Minimal newline-delimited JSON-RPC MCP server for the confine dogfood. +// argv[2] = absolute path to attempt to read via the "probe" tool. Returns a +// STATUS TOKEN (not the bytes/path) so the guard response scanner doesn't BLOCK +// and muddy the enforcement result. +const fs = require("fs"); +const SECRET = process.argv[2]; +let buf = ""; +process.stdin.on("data", (d) => { + buf += d.toString("utf8"); + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, i); + buf = buf.slice(i + 1); + if (line.trim()) handle(JSON.parse(line)); + } +}); +const send = (m) => process.stdout.write(JSON.stringify(m) + "\n"); +function handle(msg) { + switch (msg.method) { + case "initialize": + return send({ jsonrpc: "2.0", id: msg.id, result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "decoy", version: "0" }, + }}); + case "notifications/initialized": + return; + case "tools/list": + return send({ jsonrpc: "2.0", id: msg.id, result: { tools: [{ + name: "probe", + description: "diagnostic self-check", + inputSchema: { type: "object", properties: {} }, + }]}}); + case "tools/call": { + let status; + try { fs.readFileSync(SECRET); status = "read_allowed"; } + catch (e) { status = "read_denied " + (e.code || "ERR"); } + return send({ jsonrpc: "2.0", id: msg.id, result: { + content: [{ type: "text", text: status }], + }}); + } + default: + if (msg.id != null) send({ jsonrpc: "2.0", id: msg.id, result: {} }); + } +} +EOF + +# --- driver: control read, happy-path enforcement, then tamper fail-closed -- +cat > "$HOME/srv/driver.js" <<'EOF' +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); + +const die = (m) => { console.error("FAIL: " + m); process.exit(1); }; + +const entry = JSON.parse( + fs.readFileSync(path.join(process.env.HOME, ".cursor", "mcp.json"), "utf8") +).mcpServers.decoy; +if (!entry || !entry.command) die("decoy entry missing/URL-only after enable"); +if (!entry.args.includes("--confine-profile-hash")) die("config not confine-wrapped"); + +// Positive control: the SAME read succeeds when NOT sandboxed. Proves the file +// exists and is readable, so a denial inside the sandbox is caused by the +// sandbox — not by a missing file or a path typo. +const secretPath = entry.args[entry.args.length - 1]; // last orig arg = secret path +try { fs.readFileSync(secretPath); } +catch (e) { die("control read of " + secretPath + " failed unconfined (" + e.code + ") — test broken, not a real denial"); } + +function driveHappy(args) { + return new Promise((resolve) => { + const child = spawn(entry.command, args, { stdio: ["pipe", "pipe", "inherit"] }); + const seen = {}; + let buf = "", step = 1, done = false; + const to = setTimeout(() => { if (!done) { child.kill(); die("timeout — no JSON-RPC responses (stdio broken through relay/sandbox?)"); } }, 15000); + const send = (m) => child.stdin.write(JSON.stringify(m) + "\n"); + function advance() { + if (step === 1 && seen[1]) { step = 2; send({ jsonrpc: "2.0", method: "notifications/initialized" }); send({ jsonrpc: "2.0", id: 2, method: "tools/list" }); } + else if (step === 2 && seen[2]) { step = 3; send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "probe", arguments: {} } }); } + else if (step === 3 && seen[3]) { done = true; clearTimeout(to); child.kill(); resolve(seen); } + } + child.stdout.on("data", (d) => { + buf += d.toString("utf8"); + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + let m; try { m = JSON.parse(line); } catch { continue; } + if (m.id != null) seen[m.id] = m; + advance(); + } + }); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "dogfood", version: "0" } } }); + }); +} + +function runToExit(args) { + return new Promise((resolve) => { + const child = spawn(entry.command, args, { stdio: ["ignore", "ignore", "pipe"] }); + let err = ""; + child.stderr.on("data", (d) => { err += d.toString("utf8"); }); + const to = setTimeout(() => { child.kill(); resolve({ code: null, timedOut: true, err }); }, 15000); + child.on("exit", (code) => { clearTimeout(to); resolve({ code, err }); }); + }); +} + +(async () => { + // 1. happy path — real kernel sandbox + const seen = await driveHappy(entry.args); + if (!seen[1] || !seen[1].result) die("initialize returned no result — stdio broken through relay/sandbox"); + const tools = seen[2] && seen[2].result && seen[2].result.tools; + if (!Array.isArray(tools) || !tools.some((t) => t.name === "probe")) die("tools/list broken through relay"); + const text = (((seen[3] || {}).result || {}).content || [{}])[0].text || ""; + if (/^read_denied (EPERM|EACCES)/.test(text)) { + console.log("OK: stdio intact through relay + kernel sandbox; secret read denied (" + text + ")"); + } else if (/^read_denied ENOENT/.test(text)) { + die("secret not found inside sandbox (ENOENT) — path mismatch, not a real denial: '" + text + "'"); + } else { + die("secret read was NOT denied inside sandbox — enforcement FAILED: '" + text + "'"); + } + + // 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 }); + const hi = entry.args.indexOf("--confine-profile-hash"); + const tampered = entry.args.slice(); + tampered[hi + 1] = "0".repeat(64); + const r = await runToExit(tampered); + if (r.timedOut) die("tamper: wrapped process hung instead of failing closed"); + if (r.code === 0) die("tamper: mismatched profile hash did NOT fail closed (exit 0) — enforcement bypass"); + if (/PINS-READ-ERROR/.test(r.err)) die("tamper: failed closed on pins, not confine — gate not isolated"); + if (!/CONFINE-BLOCK.*hash mismatch/i.test(r.err)) die("tamper: fail-closed but NOT via the confine hash-mismatch gate — got: " + (r.err.trim().split("\n")[0] || "(no stderr)")); + console.log("OK: tampered profile hash fails closed via CONFINE gate (exit " + r.code + ")"); + + console.log("PASS: confine dogfood"); +})(); +EOF + +# --- fake cursor client with one stdio server enrolling the decoy ----------- +step "Writing fake cursor config" +node -e ' + const fs = require("fs"), path = require("path"); + const p = path.join(process.env.HOME, ".cursor", "mcp.json"); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const cfg = { mcpServers: { decoy: { command: "node", args: [ + path.join(process.env.HOME, "srv", "server.js"), + path.join(process.env.HOME, ".ssh", "decoy"), + ] } } }; + fs.writeFileSync(p, JSON.stringify(cfg, null, 2), { mode: 0o600 }); +' + +# --- enable OS confinement -------------------------------------------------- +step "mcpm guard enable --confine --client cursor" +node "$DIST" guard enable --confine --client cursor + +# --- post-enable assertions: marker + store -------------------------------- +step "Verifying enrollment (marker + store)" +node -e ' + const fs = require("fs"), path = require("path"); + const a = JSON.parse(fs.readFileSync(path.join(process.env.HOME, ".cursor", "mcp.json"), "utf8")).mcpServers.decoy.args; + if (!a.includes("--confine-profile-hash")) { console.error("FAIL: config not confine-wrapped"); process.exit(1); } + const store = path.join(process.env.HOME, ".mcpm", "guard-confine.yaml"); + if (!fs.existsSync(store)) { console.error("FAIL: confine store not written"); process.exit(1); } + if (!/tier:\s*standard/.test(fs.readFileSync(store, "utf8"))) { console.error("FAIL: store tier not standard"); process.exit(1); } + console.log("OK: decoy enrolled — marker embedded + store written (tier standard)"); +' + +# --- doctor-confine sanity -------------------------------------------------- +step "mcpm guard doctor-confine --json" +node -e ' + const { execFileSync } = require("child_process"); + const d = JSON.parse(execFileSync(process.execPath, [process.env.DIST, "guard", "doctor-confine", "--json"], { encoding: "utf8" })); + if (!d.backendAvailable) { console.error("FAIL: doctor-confine backendAvailable=false (sandbox-exec missing?)"); process.exit(1); } + const s = (d.servers || []).find((x) => x.name === "decoy"); + if (!s) { console.error("FAIL: doctor-confine does not list decoy"); process.exit(1); } + console.log("OK: doctor-confine — backend available, decoy enrolled (tier " + s.tier + ", net " + s.net + ")"); +' + +# --- live enforcement + tamper fail-closed ---------------------------------- +step "Live enforcement: control read + sandbox denial + tamper fail-closed" +node "$HOME/srv/driver.js" + +# --- confine decision recorded in the event log ----------------------------- +step "Verifying CONFINE event logged" +node -e ' + const fs = require("fs"), path = require("path"); + const p = path.join(process.env.HOME, ".mcpm", "guard-events.jsonl"); + if (!fs.existsSync(p)) { console.error("FAIL: no guard-events.jsonl"); process.exit(1); } + const events = fs.readFileSync(p, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse); + if (!events.some((e) => e.category === "CONFINE" || /confine/i.test(JSON.stringify(e)))) { console.error("FAIL: no CONFINE event recorded"); process.exit(1); } + console.log("OK: CONFINE decision recorded in guard-events.jsonl"); +' + +printf "\n\033[1;32m✓ confine dogfood PASSED\033[0m — real sandbox-exec enforcement verified end-to-end.\n" diff --git a/scripts/launch-checklist.md b/scripts/launch-checklist.md index fa6a203..1035fcd 100644 --- a/scripts/launch-checklist.md +++ b/scripts/launch-checklist.md @@ -4,6 +4,7 @@ - [ ] All tests pass: `pnpm test` - [ ] Build succeeds: `pnpm build` +- [ ] OS confinement enforcement (macOS): `pnpm dogfood:confine` — hermetic end-to-end, must print `✓ confine dogfood PASSED` (real `sandbox-exec`; not covered by ubuntu CI) - [ ] Local install works: `npm install -g .` then `mcpm --help` - [ ] `mcpm search filesystem` returns real results from the registry - [ ] `mcpm install ` completes with trust score (e.g. `io.github.domdomegg/filesystem-mcp`) diff --git a/src/commands/completions.ts b/src/commands/completions.ts index ca4da5d..4781548 100644 --- a/src/commands/completions.ts +++ b/src/commands/completions.ts @@ -38,7 +38,7 @@ export const SUBCOMMANDS = [ ]; export const GUARD_SUBCOMMANDS = - "enable disable status demo accept-drift mute unmute pause cleanup list-signatures reset-integrity run"; + "enable disable status demo accept-drift mute unmute pause cleanup list-signatures doctor-confine reset-integrity run"; export const SECRETS_SUBCOMMANDS = "set get list rm migrate"; const CLIENT_IDS = ["claude-desktop", "cursor", "vscode", "windsurf"]; diff --git a/src/commands/guard.ts b/src/commands/guard.ts index 649fe44..9bc6bdd 100644 --- a/src/commands/guard.ts +++ b/src/commands/guard.ts @@ -44,19 +44,44 @@ export function registerGuardCommand(program: Command): void { "permit URL/HTTP-transport servers to run WITHOUT runtime guard inspection " + "(no MITM relay exists for non-stdio transports); records consent so future runs stay quiet", ) + .option( + "--confine [mode]", + "enroll wrapped stdio servers in an OS sandbox (macOS Seatbelt): standard | off (default when flag present: standard)", + ) .action( async (rawOpts: { client?: string; server?: string; dryRun?: boolean; allowUnguarded?: boolean; + confine?: boolean | string; }) => { const { runEnableCommand } = await import("../guard/cli.js"); const opts = parseClientServer(rawOpts); + // `--confine` (bare) / `standard` / `true` / `1` → standard; `off` → off; + // anything else is rejected. + let confine: "standard" | "off" | undefined; + if ( + rawOpts.confine === true || + rawOpts.confine === "standard" || + rawOpts.confine === "true" || + rawOpts.confine === "1" + ) { + confine = "standard"; + } else if (rawOpts.confine === "off") { + confine = "off"; + } else if (rawOpts.confine !== undefined) { + process.stderr.write( + `mcpm guard enable: invalid --confine value "${String(rawOpts.confine)}" ` + + `(use \`--confine\`, \`--confine standard\`, or \`--confine off\`).\n`, + ); + process.exit(1); + } await runEnableCommand({ ...opts, dryRun: rawOpts.dryRun === true, allowUnguarded: rawOpts.allowUnguarded === true, + confine, write: (s) => process.stdout.write(s), }); }, @@ -229,6 +254,18 @@ export function registerGuardCommand(program: Command): void { } }); + guard + .command("doctor-confine") + .description("Report OS confinement backend availability + enrolled servers") + .option("--json", "emit machine-readable JSON") + .action(async (opts: { json?: boolean }) => { + const { runDoctorConfineCommand } = await import("../guard/cli.js"); + await runDoctorConfineCommand({ + json: opts.json === true, + write: (s) => process.stdout.write(s), + }); + }); + guard .command("reset-integrity") .description("Regenerate the pins.json or guard-policy.yaml integrity sidecar") diff --git a/src/guard/__tests__/cli-confine.test.ts b/src/guard/__tests__/cli-confine.test.ts new file mode 100644 index 0000000..19e514c --- /dev/null +++ b/src/guard/__tests__/cli-confine.test.ts @@ -0,0 +1,202 @@ +/** + * F1 PR4: confine CLI wiring — computeConfineMarkers, `enable --confine` + * end-to-end, and `doctor-confine`. Isolated HOME (real adapters + real store) + * with MCPM_DISABLE_CONFINE=1 so the OS-backend result is deterministic across + * platforms (marker embedding + the store don't depend on the backend). + */ + +import { describe, expect, test, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { _resetCachedStorePath } from "../../store/index.js"; +import { getConfigPath } from "../../config/paths.js"; +import { + computeConfineMarkers, + runEnableCommand, + runDoctorConfineCommand, +} from "../cli.js"; +import { readConfineStore } from "../confine/store.js"; +import { hashConfineProfile } from "../confine/profile.js"; + +let tmpHome: string; +let originalHome: string | undefined; + +const cursorConfig = () => getConfigPath("cursor"); // ~/.cursor/mcp.json + +function writeCursor(servers: Record): void { + const p = cursorConfig(); + mkdirSync(path.dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify({ mcpServers: servers }, null, 2), { mode: 0o600 }); +} + +function readCursor(): Record { + return JSON.parse(readFileSync(cursorConfig(), "utf-8")).mcpServers; +} + +const collect = () => { + const out: string[] = []; + return { write: (s: string) => void out.push(s), text: () => out.join("") }; +}; + +beforeEach(() => { + tmpHome = mkdtempSync(path.join(tmpdir(), "mcpm-confine-cli-")); + originalHome = process.env.HOME; + process.env.HOME = tmpHome; + process.env.MCPM_DISABLE_CONFINE = "1"; // deterministic: backend "unavailable" + _resetCachedStorePath(); +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + delete process.env.MCPM_DISABLE_CONFINE; + _resetCachedStorePath(); + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe("computeConfineMarkers", () => { + test("derives+stores a marker for an unwrapped stdio server; skips url + already-wrapped", async () => { + writeCursor({ + "fs-mcp": { command: "npx", args: ["-y", "fs"] }, + "http-mcp": { url: "https://example.com/mcp" }, // no command → skip + // an already-wrapped entry → skip (its args carry the guard marker) + "wrapped-mcp": { command: "mcpm", args: ["guard", "run", "--inner", "--server-name", "wrapped-mcp", "--orig-hash", "x", "--", "node", "s.js"] }, + }); + const io = collect(); + + const markers = await computeConfineMarkers({ write: io.write }); + + expect([...markers.keys()]).toEqual(["fs-mcp"]); + const m = markers.get("fs-mcp")!; + expect(m.profileHash).toMatch(/^[0-9a-f]{64}$/); + // Persisted to the store. + const store = await readConfineStore(); + expect(Object.keys(store.servers)).toEqual(["fs-mcp"]); + expect(store.servers["fs-mcp"]?.tier).toBe("standard"); + }); + + test("--server filter narrows to one server; merges into an existing store", async () => { + writeCursor({ + alpha: { command: "node", args: ["a.js"] }, + beta: { command: "node", args: ["b.js"] }, + }); + await computeConfineMarkers({ server: "alpha", write: collect().write }); + // Second run enrolls beta; alpha must survive (merge, not clobber). + const markers = await computeConfineMarkers({ server: "beta", write: collect().write }); + expect([...markers.keys()]).toEqual(["beta"]); + const store = await readConfineStore(); + expect(Object.keys(store.servers).sort()).toEqual(["alpha", "beta"]); + }); + + test("no wrappable stdio servers → empty map, no store write", async () => { + writeCursor({ "http-mcp": { url: "https://example.com/mcp" } }); + const markers = await computeConfineMarkers({ write: collect().write }); + expect(markers.size).toBe(0); + }); + + test("re-run REUSES an already-stored profile (no re-derive, no store churn) — multi-client retry", async () => { + writeCursor({ probe: { command: "node", args: ["x.js"] } }); + const first = await computeConfineMarkers({ write: collect().write }); + const stored = (await readConfineStore()).servers.probe!; + // Second run for the same server: must reuse the stored profile's hash and + // NOT overwrite the entry (a re-derive would mint a new captured_at → churn). + const second = await computeConfineMarkers({ write: collect().write }); + expect(second.get("probe")!.profileHash).toBe(first.get("probe")!.profileHash); + expect(second.get("probe")!.profileHash).toBe(hashConfineProfile(stored)); + expect((await readConfineStore()).servers.probe!.captured_at).toBe(stored.captured_at); + }); + + test("--client narrows enrollment to that client only", async () => { + // A server present in the (default cursor) config; a --client vscode run must + // enroll nothing from cursor (and cursor's server stays out of the store). + writeCursor({ probe: { command: "node", args: ["x.js"] } }); + const markers = await computeConfineMarkers({ client: "vscode", write: collect().write }); + expect(markers.size).toBe(0); + expect(Object.keys((await readConfineStore()).servers)).toEqual([]); + }); +}); + +describe("runEnableCommand --confine (end-to-end)", () => { + test("wraps the server WITH the confine marker + writes the profile to the store", async () => { + writeCursor({ probe: { command: "node", args: ["x.js"] } }); + const io = collect(); + + await runEnableCommand({ confine: "standard", write: io.write }); + + const entry = readCursor().probe; + expect(entry.args).toContain("--confine-profile-hash"); + expect(entry.args).toContain("--"); // marker precedes the original argv + expect(entry.args?.slice(-2)).toEqual(["node", "x.js"]); + const store = await readConfineStore(); + expect(store.servers.probe?.tier).toBe("standard"); + // LOAD-BEARING: the embedded hash VALUE must equal the stored profile's hash + // (that binding is what lets spawn-time verify detect a tampered store). A + // wrong/stale hash would pass the flag-present check but break enforcement. + const args = entry.args ?? []; + const embedded = args[args.indexOf("--confine-profile-hash") + 1]; + expect(embedded).toBe(hashConfineProfile(store.servers.probe!)); + // Backend forced unavailable → the enroll notice warns it runs unconfined. + expect(io.text()).toContain("enrolled in OS confinement"); + expect(io.text()).toContain("UNCONFINED"); + }); + + test("without --confine, no marker + no store (unchanged behavior)", async () => { + writeCursor({ probe: { command: "node", args: ["x.js"] } }); + await runEnableCommand({ write: collect().write }); + expect(readCursor().probe.args).not.toContain("--confine-profile-hash"); + // No confine store created. + const store = await readConfineStore(); + expect(Object.keys(store.servers)).toEqual([]); + }); + + test("aborts (no wrap, store left untouched) when the confine store is corrupt/tampered", async () => { + writeCursor({ probe: { command: "node", args: ["x.js"] } }); + // Structurally-invalid store, no sidecar → integrity skipped → Zod shape error + // (which readConfineStore throws). It must be aborted-on, never clobbered. + const mcpmDir = path.join(tmpHome, ".mcpm"); + mkdirSync(mcpmDir, { recursive: true }); + const storePath = path.join(mcpmDir, "guard-confine.yaml"); + const corrupt = 'format_version: 1\nservers: "not-an-object"\n'; + writeFileSync(storePath, corrupt, { mode: 0o600 }); + const io = collect(); + + await runEnableCommand({ confine: "standard", write: io.write }); + + expect(io.text()).toContain("OS confinement aborted"); + // Not wrapped (aborted before the orchestrator ran)… + expect(readCursor().probe.args ?? []).not.toContain("--confine-profile-hash"); + // …and the corrupt store was left byte-for-byte intact (not clobbered). + expect(readFileSync(storePath, "utf-8")).toBe(corrupt); + }); +}); + +describe("runDoctorConfineCommand", () => { + test("--json reports platform, backendAvailable, and enrolled servers", async () => { + writeCursor({ probe: { command: "node", args: ["x.js"] } }); + await computeConfineMarkers({ write: collect().write }); + const io = collect(); + + await runDoctorConfineCommand({ json: true, write: io.write }); + + const report = JSON.parse(io.text()); + expect(report.platform).toBe(process.platform); + expect(report.backendAvailable).toBe(false); // MCPM_DISABLE_CONFINE=1 + expect(report.servers.map((s: { name: string }) => s.name)).toContain("probe"); + }); + + test("text mode lists enrolled servers + the unconfined-here notice", async () => { + writeCursor({ probe: { command: "node", args: ["x.js"] } }); + await computeConfineMarkers({ write: collect().write }); + const io = collect(); + await runDoctorConfineCommand({ write: io.write }); + expect(io.text()).toContain("probe"); + expect(io.text()).toContain("UNAVAILABLE"); + }); + + test("no enrollment → clear empty-state message", async () => { + const io = collect(); + await runDoctorConfineCommand({ write: io.write }); + expect(io.text()).toContain("No servers enrolled"); + }); +}); diff --git a/src/guard/__tests__/orchestrator.test.ts b/src/guard/__tests__/orchestrator.test.ts index b96909f..379ea0e 100644 --- a/src/guard/__tests__/orchestrator.test.ts +++ b/src/guard/__tests__/orchestrator.test.ts @@ -192,3 +192,36 @@ describe("statusAcrossClients", () => { expect(status.clients[0]?.servers).toContainEqual({ name: "git-mcp", wrapped: false, unguarded: false }); }); }); + +// ─────────────── F1: confineMarkers embedding ─────────────── + +describe("enableGuardAcrossClients — F1 confine markers", () => { + test("embeds --confine-profile-hash for a mapped server; none for an unmapped one", async () => { + const state: Record = { + "fs-mcp": { command: "npx", args: ["-y", "fs"] }, + "git-mcp": { command: "npx", args: ["-y", "git"] }, + }; + const hash = "a".repeat(64); + const deps: OrchestratorDeps = { + ...makeDeps({ cursor: makeAdapter("cursor", state) }), + confineMarkers: new Map([["fs-mcp", { profileHash: hash, required: true }]]), + }; + + await enableGuardAcrossClients(deps); + + // The hash VALUE must immediately follow the flag (not merely appear somewhere). + const fsArgs = state["fs-mcp"]?.args ?? []; + expect(fsArgs[fsArgs.indexOf("--confine-profile-hash") + 1]).toBe(hash); + expect(fsArgs).toContain("--confine-required"); + // Unmapped server is wrapped WITHOUT confine tokens. + expect(state["git-mcp"]?.args?.[0]).toBe("guard"); + expect(state["git-mcp"]?.args).not.toContain("--confine-profile-hash"); + }); + + test("no confineMarkers → no confine tokens (unchanged wraps)", async () => { + const state: Record = { "fs-mcp": { command: "npx", args: ["fs"] } }; + const deps = makeDeps({ cursor: makeAdapter("cursor", state) }); + await enableGuardAcrossClients(deps); + expect(state["fs-mcp"]?.args).not.toContain("--confine-profile-hash"); + }); +}); diff --git a/src/guard/__tests__/pins.test.ts b/src/guard/__tests__/pins.test.ts index 419f7ab..8e93d4d 100644 --- a/src/guard/__tests__/pins.test.ts +++ b/src/guard/__tests__/pins.test.ts @@ -401,6 +401,40 @@ describe("readPins / writePins", () => { expect(pins.servers).toEqual({}); }); + // Regression (dogfood 2026-07-02): writePins used to touch pins.json to 0 + // bytes before locking. A crash/kill — or a concurrent, unlocked readPins — + // in the window between that touch and the atomic finalize write left an + // empty file, so the next launch did JSON.parse("") → PINS-READ-ERROR and + // failed the guard closed (bricked until the user manually rm'd pins.json). + // The pre-touch must write VALID content so an interrupted write is readable. + test("interrupted writePins leaves a VALID pins.json, not a 0-byte brick", async () => { + const dir = path.join(tmpHome, ".mcpm"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const filePath = path.join(dir, "pins.json"); + // Hold proper-lockfile's lock dir (`${file}.lock`) so writePins throws at + // lockfile.lock() — i.e. AFTER the initial touch, BEFORE the finalize write. + mkdirSync(`${filePath}.lock`); + + let pins = emptyPinsFile(); + pins = upsertToolPin(pins, "fs", "read", { + current_hash: "sha256:abc", + previous_hashes: [], + captured_at: "2026-07-02T00:00:00Z", + captured_via: "install", + signature_list_version: "v0.5.0", + }); + await expect(writePins(pins)).rejects.toThrow(); + + // The file left by the pre-touch must be valid JSON, never 0 bytes. + const bytes = readFileSync(filePath, "utf-8"); + expect(bytes.length).toBeGreaterThan(0); + expect(() => JSON.parse(bytes)).not.toThrow(); + + // The next launch must NOT fail closed — no sidecar yet ⇒ first-run path. + rmSync(`${filePath}.lock`, { recursive: true, force: true }); + await expect(readPins()).resolves.toBeDefined(); + }); + test("resetIntegrity refreshes the sidecar after manual edit", async () => { await writePins(emptyPinsFile()); const filePath = path.join(tmpHome, ".mcpm", "pins.json"); diff --git a/src/guard/cli.ts b/src/guard/cli.ts index faccee2..6a70636 100644 --- a/src/guard/cli.ts +++ b/src/guard/cli.ts @@ -19,7 +19,18 @@ import { type StatusReport, type OrchestratorDeps, } from "./orchestrator.js"; -import { defaultWrapContext } from "./wrap.js"; +import os from "node:os"; +import { defaultWrapContext, isWrapped, type ConfineMarker } from "./wrap.js"; +import { deriveDefaultProfile } from "./confine/derive.js"; +import { + readConfineStore, + writeConfineStore, + withProfile, + confineSandboxRoot, +} from "./confine/store.js"; +import { hashConfineProfile, type ConfineStore } from "./confine/profile.js"; +import { isConfineBackendAvailable } from "./confine/apply.js"; +import { SANDBOX_EXEC_PATH } from "./confine/backend-macos.js"; import { placeholderEnvKeys } from "../store/keychain.js"; import { readUnguardedConsent, @@ -69,6 +80,8 @@ export interface EnableOpts extends CommandIO { readonly dryRun?: boolean; /** H9: `--allow-unguarded` — consent to run url servers without the relay. */ readonly allowUnguarded?: boolean; + /** F1: `--confine` — enroll wrapped stdio servers in OS confinement (v1: standard). */ + readonly confine?: "standard" | "off"; } export async function runEnableCommand(opts: EnableOpts): Promise { @@ -76,29 +89,306 @@ export async function runEnableCommand(opts: EnableOpts): Promise { // (full UNGUARDED warning only when the set GAINS a server — additions // re-warn; removals/unchanged stay quiet — anti-rubber-stamp, §5 H9). const previousConsented = await readUnguardedConsent(); - const deps = buildDeps({ allowUnguarded: opts.allowUnguarded }); if (opts.dryRun === true) { - const status = await statusAcrossClients(deps); - opts.write("Dry-run: planned wraps\n"); - for (const c of status.clients) { - if (opts.client !== undefined && c.clientId !== opts.client) continue; - const candidates = c.servers.filter((s) => { - if (opts.server !== undefined && s.name !== opts.server) return false; - return !s.wrapped; + await printEnableDryRun(opts); + return; + } + + if (opts.confine === "off") { + // Acknowledge the flag so `--confine off` isn't a silent no-op. + opts.write("OS confinement: skipped (--confine off).\n"); + } + + // F1: build the name→marker map BEFORE the orchestrator runs, so the same + // content hash is embedded across every client that wraps a given server. A + // corrupt/tampered confine store ABORTS the enable (store left untouched) + // rather than silently clobbering previously-enrolled servers or masking tamper. + let confineMarkers: ReadonlyMap | undefined; + if (opts.confine === "standard") { + try { + confineMarkers = await computeConfineMarkers({ + client: opts.client, + server: opts.server, + write: opts.write, }); - opts.write(` ${CLIENT_LABELS[c.clientId]}: would wrap ${candidates.length} server(s)\n`); - for (const s of candidates) opts.write(` + ${s.name}\n`); + } catch (err) { + opts.write( + chalk.yellow(`\n⚠ OS confinement aborted: ${sanitize((err as Error).message)}`) + "\n", + ); + return; } - return; } + const deps = buildDeps({ allowUnguarded: opts.allowUnguarded, confineMarkers }); const summary = await enableGuardAcrossClients(deps, opts); printEnableDisable(summary, opts); printUnguardedWarning(summary, previousConsented, opts); + if (confineMarkers !== undefined) printConfineNotice(confineMarkers, opts); printRestartReminder(opts); } +async function printEnableDryRun(opts: EnableOpts): Promise { + const deps = buildDeps({ allowUnguarded: opts.allowUnguarded }); + const status = await statusAcrossClients(deps); + const confineNote = opts.confine === "standard" ? " (with OS confinement)" : ""; + opts.write(`Dry-run: planned wraps${confineNote}\n`); + for (const c of status.clients) { + if (opts.client !== undefined && c.clientId !== opts.client) continue; + const candidates = c.servers.filter((s) => { + if (opts.server !== undefined && s.name !== opts.server) return false; + return !s.wrapped; + }); + opts.write(` ${CLIENT_LABELS[c.clientId]}: would wrap ${candidates.length} server(s)\n`); + for (const s of candidates) opts.write(` + ${s.name}\n`); + } +} + +// --------------------------------------------------------------------------- +// F1 confine: marker computation + doctor +// --------------------------------------------------------------------------- + +export interface ConfineComputeOpts { + readonly client?: ClientId; + readonly server?: string; + readonly net?: "all" | "none"; + readonly requireConfine?: boolean; + readonly write: (s: string) => void; +} + +interface ConfineTarget { + readonly command: string; + readonly args?: readonly string[]; +} + +/** + * De-duped (by name) set of servers `enable` will wrap and therefore need a + * confine profile: unwrapped STDIO servers only (url/HTTP have no command; + * already-wrapped servers aren't re-wrapped), respecting `--client`/`--server`. + */ +async function collectConfineTargets(opts: ConfineComputeOpts): Promise> { + const targets = new Map(); + let clients: ClientId[]; + try { + clients = await detectInstalledClients(); + } catch { + return targets; + } + if (opts.client !== undefined) clients = clients.filter((c) => c === opts.client); + for (const clientId of clients) { + let entries; + try { + entries = await getAdapter(clientId).read(getConfigPath(clientId)); + } catch { + continue; // missing/unreadable config — skip (mirrors enable's read handling) + } + for (const [name, entry] of Object.entries(entries)) { + if (opts.server !== undefined && name !== opts.server) continue; + if (!entry.command || isWrapped(entry)) continue; + // Tie-breaker: first client wins. If the same server NAME runs a different + // command across clients (e.g. `npx` in one, `node` in another), the net + // classification is taken from the first-seen command. Pass `--client` to + // enroll per-client and avoid the ambiguity. + if (!targets.has(name)) targets.set(name, { command: entry.command, args: entry.args }); + } + } + return targets; +} + +/** + * Build the name→marker map for the servers `enable` will wrap, persisting a + * ConfineProfile for genuinely-new servers. Returns markers for the orchestrator + * to embed. Two load-bearing properties: + * + * 1. FAIL CLOSED on a corrupt/tampered store — `readConfineStore` throws only on + * a genuine integrity/shape/version error (ENOENT returns an empty store), so + * we let it PROPAGATE (the caller aborts the enable). We must never fall back + * to an empty store + overwrite: that would erase previously-enrolled servers + * and mask the tamper signal. + * 2. REUSE an already-stored profile rather than re-deriving it. A re-derive would + * mint a fresh `capturedAt`; even though captured_at is excluded from the hash, + * reusing avoids churning the store on a retry and keeps the binding stable. + * (v1 does not re-enroll a server whose command changed — that's the deferred + * per-server `guard confine` command; the existing profile is the safe choice.) + */ +export async function computeConfineMarkers( + opts: ConfineComputeOpts, +): Promise> { + const targets = await collectConfineTargets(opts); + if (targets.size === 0) return new Map(); + + let store: ConfineStore; + try { + store = await readConfineStore(); + } catch (err) { + throw new Error( + `cannot read the confine store (~/.mcpm/guard-confine.yaml): ${(err as Error).message} ` + + `Review it for unauthorized changes; if you edited it intentionally, restore or remove it. ` + + `Refusing to enroll — the store was left untouched.`, + ); + } + + const { markers, storeToWrite } = await resolveConfineMarkers(targets, store, opts); + if (storeToWrite !== null) await writeConfineStore(storeToWrite); + return markers; +} + +/** + * Build the markers for `targets`: reuse an already-stored profile (stable hash, + * no store churn) or derive+stage a new one. Returns the markers and the store to + * persist (null when nothing new was added, so the caller skips the write). + */ +async function resolveConfineMarkers( + targets: Map, + store: ConfineStore, + opts: ConfineComputeOpts, +): Promise<{ markers: Map; storeToWrite: ConfineStore | null }> { + const markers = new Map(); + const home = os.homedir(); + const sandboxRoot = await confineSandboxRoot(); + const tmpDir = os.tmpdir(); + const capturedAt = new Date().toISOString(); + let next = store; + let added = 0; + for (const [name, target] of targets) { + const existing = Object.hasOwn(store.servers, name) ? store.servers[name] : undefined; + if (existing !== undefined) { + markers.set(name, { + profileHash: hashConfineProfile(existing), + required: existing.require_confine, + }); + continue; + } + const profile = deriveDefaultProfile({ + serverName: name, + command: target.command, + args: target.args, + home, + sandboxRoot, + tmpDir, + capturedAt, + requireConfine: opts.requireConfine, + net: opts.net, + }); + next = withProfile(next, name, profile); + added += 1; + markers.set(name, { + profileHash: hashConfineProfile(profile), + required: profile.require_confine, + }); + } + return { markers, storeToWrite: added > 0 ? next : null }; +} + +function printConfineNotice( + confineMarkers: ReadonlyMap, + opts: CommandIO, +): void { + if (confineMarkers.size === 0) { + opts.write("\nOS confinement: no unwrapped stdio servers to enroll.\n"); + return; + } + opts.write( + `\n🔒 ${confineMarkers.size} server(s) enrolled in OS confinement (standard tier). ` + + "Run `mcpm guard doctor-confine` for status.\n", + ); + if (!isConfineBackendAvailable()) { + opts.write( + chalk.yellow( + "⚠ No OS sandbox backend on this platform — enrolled servers run UNCONFINED until a " + + "backend is present (they are NOT blocked; a require_confine server would fail closed).", + ) + "\n", + ); + } +} + +export interface DoctorConfineOpts extends CommandIO { + readonly json?: boolean; +} + +interface DoctorConfineServer { + readonly name: string; + readonly tier: string; + readonly net: string; + readonly requireConfine: boolean; +} + +export async function runDoctorConfineCommand(opts: DoctorConfineOpts): Promise { + const backendAvailable = isConfineBackendAvailable(); + let servers: DoctorConfineServer[] = []; + let storeError: string | undefined; + try { + const store = await readConfineStore(); + servers = Object.entries(store.servers).map(([name, p]) => ({ + name, + tier: p.tier, + net: p.net, + requireConfine: p.require_confine, + })); + } catch (err) { + storeError = (err as Error).message; + } + opts.write( + opts.json === true + ? renderDoctorConfineJson(backendAvailable, servers, storeError) + : renderDoctorConfineText(backendAvailable, servers, storeError), + ); +} + +function renderDoctorConfineJson( + backendAvailable: boolean, + servers: readonly DoctorConfineServer[], + storeError: string | undefined, +): string { + return ( + JSON.stringify( + { + platform: process.platform, + backendAvailable, + sandboxExecPath: process.platform === "darwin" ? SANDBOX_EXEC_PATH : null, + // sanitize: an OS error message can carry control chars / ANSI (parity + // with the text branch, which already sanitizes). + storeError: storeError !== undefined ? sanitize(storeError) : null, + servers, + }, + null, + 2, + ) + "\n" + ); +} + +function renderDoctorConfineText( + backendAvailable: boolean, + servers: readonly DoctorConfineServer[], + storeError: string | undefined, +): string { + const out: string[] = ["mcpm guard doctor-confine", ""]; + out.push(` platform : ${process.platform}`); + let backendLine = ` sandbox backend : ${backendAvailable ? "available" : "UNAVAILABLE"}`; + if (process.platform === "darwin") backendLine += ` (${SANDBOX_EXEC_PATH})`; + out.push(backendLine); + if (!backendAvailable) { + out.push( + " → enrolled servers run UNCONFINED here (hybrid posture); a require_confine server fails closed.", + ); + } + out.push(""); + if (storeError !== undefined) { + out.push(` ⚠ could not read the confine store: ${sanitize(storeError)}`, ""); + return out.join("\n"); + } + if (servers.length === 0) { + out.push(" No servers enrolled in confinement. Enroll with `mcpm guard enable --confine`.", ""); + return out.join("\n"); + } + out.push(` Enrolled servers (${servers.length}):`); + for (const s of servers) { + out.push(` ${sanitize(s.name)} — tier=${s.tier} net=${s.net} require_confine=${s.requireConfine}`); + } + out.push("", " Run `mcpm guard status` for per-client wrap state.", ""); + return out.join("\n"); +} + /** * H9 (A.4): "warn once unless the set changes". Emit the full multi-line * UNGUARDED warning only when this run ADDS a server to the consented-unguarded diff --git a/src/guard/confine/__tests__/profile.test.ts b/src/guard/confine/__tests__/profile.test.ts index 8a18732..a4b8d4a 100644 --- a/src/guard/confine/__tests__/profile.test.ts +++ b/src/guard/confine/__tests__/profile.test.ts @@ -45,6 +45,16 @@ describe("hashConfineProfile", () => { expect(hashConfineProfile({ ...base, read_deny: [] })).not.toBe(h); expect(hashConfineProfile({ ...base, require_confine: true })).not.toBe(h); expect(hashConfineProfile({ ...base, write_allow: ["/tmp", "/etc"] })).not.toBe(h); + expect(hashConfineProfile({ ...base, tier: "standard", scratch_dir: "/other" })).not.toBe(h); + }); + + test("EXCLUDES captured_at (provenance, not policy) — else a re-derive at a new time would false-tamper", () => { + // Two derivations of the same policy at different times MUST hash equal, or a + // multi-client partial-enable + retry (fresh timestamp) would mint a hash that + // mismatches already-wrapped markers → spawn row 3 fail-closed on a good server. + expect(hashConfineProfile({ ...base, captured_at: "2099-12-31T23:59:59Z" })).toBe( + hashConfineProfile(base), + ); }); }); diff --git a/src/guard/confine/profile.ts b/src/guard/confine/profile.ts index 9081a2e..71db2b4 100644 --- a/src/guard/confine/profile.ts +++ b/src/guard/confine/profile.ts @@ -96,7 +96,16 @@ function canonicalize(value: unknown): unknown { * the marker↔store binding load-bearing: a same-user attacker who rewrites the * stored profile body (issue #19 — the sidecar is unkeyed) changes this hash, so * the spawn-time recompute no longer matches the marker and the run fails closed. + * + * Hashes ONLY the enforcement-policy fields — `captured_at` is provenance + * metadata, not policy, and is DELIBERATELY excluded. If it were hashed, two + * derivations of the same policy at different times would hash differently, so a + * multi-client partial-enable + retry (which re-derives an already-enrolled + * server with a fresh timestamp) would mint a new hash that no longer matches the + * already-wrapped clients' markers → spawn decision-table row 3 (fail-closed + * ALWAYS) would brick a working server with a bogus tamper alarm. */ export function hashConfineProfile(profile: ConfineProfile): string { - return createHash("sha256").update(JSON.stringify(canonicalize(profile))).digest("hex"); + const { captured_at: _capturedAt, ...policy } = profile; + return createHash("sha256").update(JSON.stringify(canonicalize(policy))).digest("hex"); } diff --git a/src/guard/orchestrator.ts b/src/guard/orchestrator.ts index 8a9cfeb..238f20a 100644 --- a/src/guard/orchestrator.ts +++ b/src/guard/orchestrator.ts @@ -17,7 +17,7 @@ import { copyFile } from "node:fs/promises"; import type { ClientId } from "../config/paths.js"; import type { ConfigAdapter, McpServerEntry } from "../config/adapters/index.js"; -import { wrapEntry, unwrapEntry, isWrapped, type WrapContext } from "./wrap.js"; +import { wrapEntry, unwrapEntry, isWrapped, type WrapContext, type ConfineMarker } from "./wrap.js"; export interface ClientReport { readonly clientId: ClientId; @@ -52,6 +52,13 @@ export interface OrchestratorDeps { readonly getAdapter: (clientId: ClientId) => ConfigAdapter; readonly getConfigPath: (clientId: ClientId) => string; readonly wrapContext: WrapContext; + /** + * F1: per-server confine info to embed into the wrap marker at enable time, + * keyed by server name. Built by the cli layer (derive + store the profile, + * then map name → {profileHash, required}). A name absent from the map is + * wrapped without confinement (today's behavior). Only consulted on `enable`. + */ + readonly confineMarkers?: ReadonlyMap; /** * H9: per-invocation consent to run URL/HTTP-transport (unwrappable) servers * UNGUARDED. Set from the `--allow-unguarded` flag. When absent/false, a url @@ -311,7 +318,10 @@ async function planForClient( } continue; } - transforms.push({ name, nextEntry: wrapEntry(name, entry, deps.wrapContext) }); + transforms.push({ + name, + nextEntry: wrapEntry(name, entry, deps.wrapContext, deps.confineMarkers?.get(name)), + }); } else { if (!isWrapped(entry)) { skipped.push({ name, reason: "not wrapped" }); diff --git a/src/guard/pins.ts b/src/guard/pins.ts index e42cede..25522ca 100644 --- a/src/guard/pins.ts +++ b/src/guard/pins.ts @@ -359,10 +359,15 @@ export async function writePins(pins: PinsFile): Promise { const sidecarPath = await integrityPath(); const serialized = `${JSON.stringify(pins, null, 2)}\n`; - // Touch the file first if it doesn't exist — proper-lockfile requires - // the target to exist before locking. + // Touch the file first if it doesn't exist — proper-lockfile requires the + // target to exist before locking. Write VALID pins content, NOT "": a crash + // (or a concurrent, unlocked readPins) between this touch and the atomic + // write below must never observe a 0-byte pins.json — that throws + // PINS-READ-ERROR and fails the guard closed / bricks the next launch. + // readPins treats an absent sidecar as first-run, so this sidecar-less + // intermediate parses cleanly; the lock+atomic writes below finalize it. try { - await writeFile(filePath, "", { flag: "wx", mode: 0o600 }); + await writeFile(filePath, serialized, { flag: "wx", mode: 0o600 }); } catch (err) { if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; }