feat(guard): F1 confine core — OS sandbox (macOS Seatbelt) + spawn decision table#110
Merged
Conversation
…cision table
The guard relay is a stdio MITM that inspects JSON-RPC but does NOT contain the
child it spawns — a server that decides to read ~/.ssh or write ~/Library/
LaunchAgents never expresses that through inspectable traffic. F1 adds the first
ENFORCEMENT primitive for that non-inspectable half: an OS sandbox wrapping the
relayed child. macOS first (Linux bwrap deferred).
New src/guard/confine/:
- profile.ts — ConfineProfile Zod schema + CONFINE_FORMAT_VERSION + a
key-order-stable content hash (the marker↔store binding).
- derive.ts — standard-tier factory: secret-dir READ denylist, WRITE allowlist
(deny all of $HOME except caches/scratch/temp — blocks the whole
persistence class in one rule), launcher NET classifier, and a
traversal-safe scratch segment.
- backend-macos.ts — Seatbelt SBPL renderer (allow-default + last-match-wins,
verified live on macOS 26), `sandbox-exec -p` (no on-disk .sb),
SBPL string-escaping, memoized availability check.
- apply.ts — wrapForConfinement dispatcher (null when no backend).
- store.ts — ~/.mcpm/guard-confine.yaml, FAIL-CLOSED on integrity/shape/
version (pins.ts model), source of truth for "is X enrolled".
- decide.ts — the 9-row spawn decision table (pure, exhaustively tested).
Marker (wrap.ts): two tokens emitted before `--`, provably neutral to
hashOriginalEntry + origStartIdx so --orig-hash + unwrap are unaffected.
`--confine-profile-hash <hex>` is a CONTENT hash (a same-user store rewrite is
caught); `--confine-required` replicates require_confine into the IDE config so a
required server still fails closed if the store is wiped. Enable-time 64-hex
validation; parse-time hex guard against a crafted `--` value.
Spawn (run-inner.ts): backendAvailable is PRE-CHECKED (never via the child error
event, preserving H9 forensics); the store is read as source-of-truth so a
stripped marker on an enrolled server is caught. Strip-bypass, tamper (hash
mismatch / malformed hash), and whole-store-deletion all fail closed for required
servers; the hybrid posture warns + runs unconfined (never silently) otherwise.
Verified: 1876 tests pass; tsc + build clean. Two adversarial reviews (security +
TS-correctness) — all HIGH/MEDIUM findings fixed (write-only-scratch, wrapped→null
silent-degrade, malformed-hash forensics, hash validation, Object.hasOwn, memoize).
macOS enforcement dogfood 7/7 on real sandbox-exec: ~/.ssh and ~/.mcpm reads
denied, $HOME writes denied, /tmp + scratch read/write allowed, stdio intact.
CLI surface (guard confine / doctor-confine / enable --confine) + orchestrator
marker embedding land in the follow-up PR; this PR is the fully-tested engine.
m1ngshum
added a commit
that referenced
this pull request
Jul 2, 2026
Document the confine feature across every doc it touches, driven by one authoritative fact sheet + a consistency critic: - docs/GUARD.md — new "OS confinement (--confine)" section (enforcement-vs- detection, the standard READ/WRITE/NET tier, `enable --confine` + `doctor-confine`, unconfine-via-disable, hybrid posture, marker tokens, fail-closed cases, macOS-only + CI-untested caveats, deferred per-server command); the two new commands + confine store/sidecar in the tables; the CONFINE-category event ids; and the --orig-hash spawn-verify (Phase-1) note. - README.md — concise "Confinement (opt-in enforcement)" note + the two commands. - docs/ROADMAP.md — F1 marked SHIPPED (engine + enable-path, unreleased); the XL-not-L / "no spawn-verify existed, built from scratch" correction; deferred items; Next-up → F10/F8/F9. - CLAUDE.md — v0.15.0 header + confine-unreleased note, guard doctor-confine / enable --confine in the commands list, an F1 SHIPPED roadmap block (#108–#111), 7 Decisions-Log rows (2026-07-02), and a stale Next-up line reconciled (F7 done). - CHANGELOG.md — [Unreleased] entries for #108/#109/#110/#111 (no invented version). - docs/SECURITY-HARDENING.md — cross-reference note (runtime containment now has an implementation via ROADMAP F1); H1–H12 table untouched. - docs/SIGNATURES.md — clarify confine adds no OWASP signatures (catalog still 9/8); its CONFINE events + orig-hash-mismatch are relay/spawn events. - docs/ARCHITECTURE.md — confine layer in the guard subsystem (modules, store, spawn-decision diagram, 13 guard subcommands). Honest throughout: macOS-only, opt-in, CI-untested-on-sandbox-path, unreleased, per-server `guard confine` deferred; no cross-platform / general-egress / release overclaims (critic-verified).
m1ngshum
added a commit
that referenced
this pull request
Jul 2, 2026
…r-confine (#111) * feat(guard): F1 confine — make it user-reachable via `enable --confine` + `doctor-confine` Wires the PR3 confine engine to the enable path. `mcpm guard enable --confine` (bare → standard tier; `--confine off` disables) now enrolls every unwrapped stdio server it wraps into an OS sandbox: the cli layer derives each server's ConfineProfile ONCE (a single captured_at → an identical content hash across every client that wraps it, so the spawn-time verify never mismatches), merges them into ~/.mcpm/guard-confine.yaml, and hands the orchestrator a name→marker map. The orchestrator's single wrapEntry call embeds `--confine-profile-hash` (+ `--confine-required`) via the new optional `confineMarkers` dep — the only behavioral change there. `mcpm guard disable` removes the marker (unconfines); the lingering profile is harmless (spawn row 5b: no marker → not confined). `mcpm guard doctor-confine [--json]` reports backend availability (platform + sandbox-exec presence) and the enrolled servers (tier/net/require_confine), pointing at `guard status` for per-client wrap state. Read-only. Hybrid posture surfaced honestly: when no OS backend is present (Linux/CI/ Windows today), enable still enrolls + embeds markers but prints a loud notice that those servers run UNCONFINED until a backend exists (a require_confine server would fail closed at spawn per the PR3 decision table). Verified: 1886 tests pass (+10: orchestrator marker-embed, computeConfineMarkers dedup/skip/merge, enable --confine end-to-end, doctor JSON/text/empty); tsc + build clean. macOS dogfood on the built binary: `enable --confine` wrote both markers before `--` (neutrality holds live — unwrap still verifies orig-hash through them), the profile landed in guard-confine.yaml, `doctor-confine --json` reported backendAvailable=true + the enrolled server, and `disable` removed the marker cleanly. Deferred to a fast-follow: the per-server `guard confine <server>` / `--off` / `--show` / `--require` / `--allow-*` command (re-wrap-already-wrapped + the --off re-wrap-before-delete ordering contract are a distinct chunk). * docs: reconcile all docs with the F1 guard --confine arc (#108–#111) Document the confine feature across every doc it touches, driven by one authoritative fact sheet + a consistency critic: - docs/GUARD.md — new "OS confinement (--confine)" section (enforcement-vs- detection, the standard READ/WRITE/NET tier, `enable --confine` + `doctor-confine`, unconfine-via-disable, hybrid posture, marker tokens, fail-closed cases, macOS-only + CI-untested caveats, deferred per-server command); the two new commands + confine store/sidecar in the tables; the CONFINE-category event ids; and the --orig-hash spawn-verify (Phase-1) note. - README.md — concise "Confinement (opt-in enforcement)" note + the two commands. - docs/ROADMAP.md — F1 marked SHIPPED (engine + enable-path, unreleased); the XL-not-L / "no spawn-verify existed, built from scratch" correction; deferred items; Next-up → F10/F8/F9. - CLAUDE.md — v0.15.0 header + confine-unreleased note, guard doctor-confine / enable --confine in the commands list, an F1 SHIPPED roadmap block (#108–#111), 7 Decisions-Log rows (2026-07-02), and a stale Next-up line reconciled (F7 done). - CHANGELOG.md — [Unreleased] entries for #108/#109/#110/#111 (no invented version). - docs/SECURITY-HARDENING.md — cross-reference note (runtime containment now has an implementation via ROADMAP F1); H1–H12 table untouched. - docs/SIGNATURES.md — clarify confine adds no OWASP signatures (catalog still 9/8); its CONFINE events + orig-hash-mismatch are relay/spawn events. - docs/ARCHITECTURE.md — confine layer in the guard subsystem (modules, store, spawn-decision diagram, 13 guard subcommands). Honest throughout: macOS-only, opt-in, CI-untested-on-sandbox-path, unreleased, per-server `guard confine` deferred; no cross-platform / general-egress / release overclaims (critic-verified). * fix(guard): address PR #111 adversarial-review findings (2 HIGH + mediums) Two independent agent reviews (security spawn-path + TS-correctness) on #111. HIGH — false "tamper" fail-closed on a multi-client partial-enable retry. `hashConfineProfile` included `captured_at`, and `computeConfineMarkers` re-timestamped each run, so re-deriving an already-enrolled server minted a new hash that no longer matched the already-wrapped clients' markers → spawn row 3 (fail-closed ALWAYS) bricked a working server with a bogus tamper alarm. Fixed at the root: hash ONLY the enforcement-policy fields (captured_at is provenance, not policy), AND reuse an already-stored profile instead of re-deriving (only derive + store genuinely-new names; write only when ≥1 added). HIGH — silent clobber of a corrupt/tampered confine store. computeConfineMarkers caught any readConfineStore() error, fell back to an empty store, and overwrote the file — erasing previously-enrolled servers and masking the tamper signal. Now the error PROPAGATES (ENOENT still returns empty, never throws); runEnableCommand catches it, prints "⚠ OS confinement aborted", and returns WITHOUT wrapping — the store is left byte-for-byte intact (fail-closed, mirrors cleanup's PinsIntegrityError). HIGH — the load-bearing invariant was untested + 3 functions over the 50-line rule. The e2e test now asserts the embedded --confine-profile-hash VALUE equals hashConfineProfile(readConfineStore().servers[name]); extracted printEnableDryRun / collectConfineTargets / renderDoctorConfine{Json,Text} to get under 50 lines. MEDIUM — thread --client into computeConfineMarkers (was ignored; also removes the multi-client net-misclassification); acknowledge `--confine off` (was a silent no-op); sanitize storeError in the JSON doctor output. LOW — accept `--confine true`/`1` as standard (friendlier than exit 1); strengthen the orchestrator arg-adjacency assertion. Verified: 1890 tests pass (+4); tsc + build clean. macOS dogfood on the built binary: embedded hash == stored-profile hash; a retry re-run enrolls nothing new and does NOT churn captured_at (proves the no-false-tamper fix); --client scopes enrollment; disable still unwraps through the confine tokens. * refactor(guard): close the two remaining #111 review nits (function length + tie-breaker doc) Post-fix re-review of #111 confirmed all HIGH/MEDIUM resolved and a clean confine security verdict, leaving two minor items: - MEDIUM (house rule <50 lines): `computeConfineMarkers` was ~54 lines. Extract the reuse-or-derive loop into `resolveConfineMarkers(targets, store, opts)`; both functions are now well under 50. Behavior identical. - LOW: document the first-client-wins tie-breaker in `collectConfineTargets` — when the same server name runs a different command across clients, the net classification comes from the first-seen command; `--client` avoids the ambiguity. No behavior change. 1890 tests pass; tsc + build clean. * test(guard): hermetic macOS dogfood for --confine enforcement Drives the real enable -> guard run -> decide -> sandbox-exec -> relay chain in a throwaway $HOME (canonicalized via pwd -P so Seatbelt subpath rules match). Asserts, with a positive control (same secret reads fine unconfined): stdio intact through the relay+sandbox, secret read denied inside the sandbox (EPERM, not ENOENT), tamper fails closed via the CONFINE gate, and a CONFINE event is logged. Wired as 'pnpm dogfood:confine' and added to the launch checklist. macOS-only (sandbox-exec); SKIPs elsewhere. Covers the gap ubuntu CI can't. * fix(guard): writePins must never leave a 0-byte pins.json writePins touched pins.json to 0 bytes (flag: wx) before locking + writing real content. A crash/kill -- or a concurrent, unlocked readPins -- in that window left an empty file, so the next launch did JSON.parse("") -> PINS-READ-ERROR and failed the guard closed (bricked until manual rm). Pre-existing; surfaced by the confine dogfood. Fix: touch with the valid serialized content, not "", so an interrupted write is still readable (absent sidecar => first-run path). Regression test simulates the crash window by holding proper-lockfile's lock dir so writePins throws after the touch, before finalize; asserts the file left behind is non-empty valid JSON and readPins does not brick. Verified it fails on the old code and passes on the fix.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The guard relay is a stdio MITM: it inspects every JSON-RPC frame but does not contain the child MCP server it spawns. A server that decides to read
~/.sshor write~/Library/LaunchAgents/*.plistnever expresses that intent through inspectable traffic — every shipped guard feature is detection and shares that ceiling. F1 adds the first enforcement primitive: an OS sandbox (macOS Seatbelt /sandbox-exec) wrapping the relayed child. macOS first; Linuxbwrapdeferred.This is the fully-tested engine. The user-facing CLI (
guard confine/doctor-confine/enable --confine) + orchestrator marker-embedding land in the follow-up PR.Design highlights
Standard-tier profile (
derive.ts): READ = allow-all except a secret-dir denylist (~/.ssh, cloud creds, keychains, browser cookies, sibling MCP client configs, mcpm's own~/.mcpm). WRITE = allowlist (scratch + caches + temp +/dev) — denying all of$HOMEexcept caches blocks the entire persistence class (~/.zshrc, LaunchAgents, PATH-shadowing, git hooks) in one rule, which a write-denylist can't enumerate. NET = launcher-classifiedall/none(npx/uvx fetch at launch →all; else egress-deny).Marker binding (
wrap.ts): two tokens emitted before--, provably neutral tohashOriginalEntry+origStartIdx(so--orig-hash+unwrapEntryare unaffected).--confine-profile-hash <hex>is a content hash of the rendered profile (a same-user store rewrite, issue #19, is caught).--confine-requiredreplicatesrequire_confineinto the IDE config (a different file than the store) so a required server survives a wiped store.Spawn decision table (
decide.ts, 9 rows, pure + exhaustively tested): CONFINE / hybrid (fail-closed if required, else warn+unconfined) / fail-closed always on hash mismatch, malformed hash, strip-bypass on a required server, or store-wiped on a required server.backendAvailableis pre-checked (never via the childerrorevent) so a missingsandbox-execcan't misattribute H9's spawn-failure forensics to the wrapper.Store (
store.ts):~/.mcpm/guard-confine.yaml, fail-closed on integrity/shape/version (pins.ts model, not policy.ts's fail-open) — a corrupt store must never silently disable confinement.Verification
tsc --noEmit+pnpm buildclean~/.mcpmdeny → re-allow reads),wrapForConfinement→null silent-degrade (now fail-closed/warn, never silent), malformed-hash forensics (dedicatedconfine-marker-malformedevent), enable-time hash validation,Object.hasOwnlookup,existsSyncmemoization, interior-..scratch collapse.sandbox-exec:~/.ssh+~/.mcpmreads → Operation not permitted;$HOMEwrites denied;/etc/hosts+/tmp+ scratch read/write allowed; stdout flows (stdio fds intact).Files
New
src/guard/confine/{profile,derive,backend-macos,apply,store,decide}.ts(+ 5 test files). Editedwrap.ts(marker),commands/guard.ts(options + threading),run-inner.ts(decision table + backend pre-check).Risk
Confinement is opt-in per server and there is no CLI to enable it yet (this PR), so the runtime behavior of existing installs is unchanged — the spawn path only acts when a server is enrolled in
guard-confine.yaml(nothing is, until the follow-up PR wires enrollment). The marker parse/neutrality changes are covered by the wrap suite.