Skip to content

feat(guard): F1 confine core — OS sandbox (macOS Seatbelt) + spawn decision table#110

Merged
m1ngshum merged 1 commit into
mainfrom
feat/guard-confine-core
Jul 2, 2026
Merged

feat(guard): F1 confine core — OS sandbox (macOS Seatbelt) + spawn decision table#110
m1ngshum merged 1 commit into
mainfrom
feat/guard-confine-core

Conversation

@m1ngshum

@m1ngshum m1ngshum commented Jul 1, 2026

Copy link
Copy Markdown
Member

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 ~/.ssh or write ~/Library/LaunchAgents/*.plist never 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; Linux bwrap deferred.

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.

Roadmap correction: the roadmap claimed F1 "rides the existing --orig-hash spawn-verify" with "one call-site change." That spawn-verify did not exist (--orig-hash was verified only on the disable/unwrap path — closed separately in #108). This PR builds real spawn-verify from scratch.

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 $HOME except caches blocks the entire persistence class (~/.zshrc, LaunchAgents, PATH-shadowing, git hooks) in one rule, which a write-denylist can't enumerate. NET = launcher-classified all/none (npx/uvx fetch at launch → all; else egress-deny).

Marker binding (wrap.ts): two tokens emitted before --, provably neutral to hashOriginalEntry + origStartIdx (so --orig-hash + unwrapEntry are unaffected). --confine-profile-hash <hex> is a content hash of the rendered profile (a same-user store rewrite, issue #19, is caught). --confine-required replicates require_confine into 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. backendAvailable is pre-checked (never via the child error event) so a missing sandbox-exec can'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

  • 1876 tests pass · tsc --noEmit + pnpm build clean
  • ✅ Two adversarial reviews (security spawn-path + TS correctness) — all HIGH/MEDIUM fixed: write-only-scratch (scratch under ~/.mcpm deny → re-allow reads), wrapForConfinement→null silent-degrade (now fail-closed/warn, never silent), malformed-hash forensics (dedicated confine-marker-malformed event), enable-time hash validation, Object.hasOwn lookup, existsSync memoization, interior-.. scratch collapse.
  • macOS enforcement dogfood — 7/7 on real sandbox-exec: ~/.ssh + ~/.mcpm reads → Operation not permitted; $HOME writes denied; /etc/hosts + /tmp + scratch read/write allowed; stdout flows (stdio fds intact).

CI is ubuntu-only, so the macOS sandbox-exec path is exercised by mocked arg-vector unit tests in CI + local darwin dogfood (same gap os-keychain's darwin shell-outs already carry).

Files

New src/guard/confine/{profile,derive,backend-macos,apply,store,decide}.ts (+ 5 test files). Edited wrap.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.

…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 m1ngshum merged commit 28d43e8 into main Jul 2, 2026
7 checks passed
@m1ngshum m1ngshum deleted the feat/guard-confine-core branch July 2, 2026 03:10
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant