Skip to content

Commit 538bb61

Browse files
authored
fix: preserve Claude Code managed policy across guard writes (#324)
* fix: preserve Claude Code managed policy across guard writes * chore: prepare v0.50.1 release
1 parent c9b9c59 commit 538bb61

23 files changed

Lines changed: 939 additions & 110 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ _Add entries here, never under a stamped version_ — a release commit renames t
88
heading, and a branch that wrote beneath it merges without conflict straight into a
99
published section (it happened to #170).
1010

11+
## [0.50.1] - 2026-11-06
12+
13+
### Fixed
14+
15+
- Preflight Claude Code stdio file-policy decisions before wrapping or rewriting launches, including original and wrapper argv; refuse local denial or unknown policy without weakening managed controls, and surface unobserved host-policy planes. Guard policy refusals return exit 1, including dry-run; existing wrappers are audited and exact teardown can restore native denial. Previews no longer create the consent/confinement store directory merely to read absent state.
16+
1117
### Documentation
1218

1319
- Share current contributor instructions through AGENTS.md, with a thin Claude import and repo-owned development/release skills; retain the former context file in a dated historical archive.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ Those 20 points **inform the score but cannot clear a safety floor.** The MCP se
299299
| `mcpm completions <shell>` | Generate shell completion scripts (bash, zsh, fish) |
300300
| `mcpm why <name>` | Explain a server's trust score (breakdown of all components) |
301301
| `mcpm serve` | Start mcpm as an MCP server (stdio transport) |
302-
| `mcpm guard enable` | Wrap detected client configs with the inspection relay |
302+
| `mcpm guard enable` | Wrap detected client configs with the inspection relay; Claude Code stdio gets a [file-policy preflight](docs/MANAGED-POLICY-VALIDATION.md), with host coverage unknown |
303303
| `mcpm guard disable` | Restore original client configs |
304304
| `mcpm guard status` | Show what's wrapped and the per-server pin state |
305305
| `mcpm guard demo` | Run the synthetic prompt-injection scenario (visible block) |

‎docs/BACKLOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ writes, recursive whole-home discovery or native policy reimplementation.
6565

6666
## #122 — Managed-policy-aware setup and wrap plan
6767

68-
**Status:** existing safety gap. **User/problem:** changing the command to a
68+
**Status:** candidate built by Sol and reviewed/fixed by Astra; 19 native cases and 87 protocol comparisons passed. Not merged or released. See [projection and limits](MANAGED-POLICY-VALIDATION.md). **User/problem:** changing the command to a
6969
wrapper can change a client's allow/deny decision. **MVP:** read one supported,
7070
versioned policy source set and compare current/proposed launches before writes.
7171
Report exact reason and uncertainty; preserve native restrictions.

‎docs/CONTRACTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ do-not-proceed.
2121
| `mcpm scope <project-root>` | fixed candidates inventoried (present or absent) | `1` for an invalid root or incomplete fixed inventory | read-only Claude Code inventory, not a health/activation gate; `--json` emits version-1 source/metadata/coverage model. Unsupported host coverage remains unknown even on exit `0`; see [scope contract](PROJECT-SCOPE.md) |
2222
| `mcpm doctor` | no blocking issues | `1` | health check; the cross-client advisory section never changes the exit code |
2323
| `mcpm install` | installed | `1` | non-zero on a policy/trust block (`--min-trust`, `--min-release-age`, a registry-**deleted** server) or any failure |
24+
| `mcpm guard enable` / `disable` | no Claude Code file-policy refusal | `1` on a denied or unknown Claude Code file-policy transformation, including enable `--dry-run` | file-policy projection only; overall host policy stays unknown. Policy refusals count as errors and do not create config backups or confine profiles; unrelated servers may have succeeded |
2425
| `mcpm guard run` (relay) | child exit `0` | child's code; `1` | propagates the wrapped child's exit; **fails closed with `1`** on a confine hash mismatch, a stripped required marker, or a pins-integrity error |
2526
| `mcpm guard cleanup` | ran: pruned, a dry run, or nothing to prune | `1` when it refuses | refuses when `pins.json` cannot be read or fails its integrity check, and when a pin is held by no readable client config while some detected client's config cannot be read (the servers there are unknown, so the pin cannot be proven orphaned). **Changed in 0.43.0 (#236):** every refusal exited **`0`**, so `mcpm guard cleanup --yes && <next step>` read a refusal as success |
2627
| `mcpm info` / `mcpm why` | found | `1` when the named server is not in the registry | **Changed in 0.42.0:** both printed `Server '<name>' not found` and exited **`0`**, so `mcpm info X && <next step>` ran the next step on a server that is not there. The message and its stream (stdout) are unchanged; only the code moved, to match `install`/`remove` and the general rule below |

‎docs/GUARD.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,14 @@ Bare invocation. Shows status if any servers are wrapped, otherwise prints help.
170170
### `mcpm guard enable [--client <id>] [--server <name>] [--dry-run]`
171171
Wraps detected client configs. Use `--client` to scope to one (`claude-desktop` / `claude-code` / `cursor` / `vscode` / `windsurf` / `gemini-cli`); `--server` to scope to a single server name. `--dry-run` prints the planned changes without writing.
172172

173+
Claude Code stdio writes now compare current/original and proposed launches with
174+
the versioned 2.1.296 file-policy projection before wrapping, backups or confine
175+
profile writes. Denied or unknown local policy refuses with exit 1 (dry-run too).
176+
A passing file projection still reports overall host policy unknown; unobserved
177+
server-managed/MDM/session/other-project restrictions can change native behavior.
178+
See [supported sources, matching and limits](MANAGED-POLICY-VALIDATION.md).
179+
180+
173181
The wrap transformation in JSON:
174182
```
175183
{ "command": "<orig>", "args": [...], "env": {...} }
@@ -444,3 +452,8 @@ If guard itself crashes (e.g. on PATH disruption — the wrapped command points
444452
- HTTP-transport servers — v0.5.0 wraps stdio only. HTTP guard is deferred (v0.10.0 made HTTP/SSE fail-closed deny-by-default; a streamable-HTTP MITM relay is still the full fix)
445453

446454
**Performance budget:** spike measured 0.065ms p99 small / 3.1ms p99 large message overhead through the SDK framing helpers (OQ1 closed). With NFKC + 15 regexes per leaf added on top, expected p99 is < 10ms for large messages.
455+
456+
Existing Claude Code wrappers are audited by the supported enable/dry-run policy
457+
preflight. Upgrade alone does not rewrite them or stop running servers. An
458+
authorized exact unwrap can restore native command denial; it does not grant
459+
permission or provide a runtime policy-enforcement layer.

‎docs/MAINTAINER-WORKFLOW.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
Current published CLI: **v0.50.0**. The bounded Claude Code scope inventory from
66
project backlog #141 shipped; broader host/project coverage remains open.
7-
Next selected work is **#122 managed-policy-aware setup**. This is context, not
8-
authorization to start it. See the [active backlog](BACKLOG.md) and
7+
**#122 managed-policy-aware setup** is a reviewed candidate, not yet merged or
8+
released; its [candidate validation](MANAGED-POLICY-VALIDATION.md) records the
9+
bounded Claude Code profile and migration limits. See the [active backlog](BACKLOG.md) and
910
[v0.50.0 release evidence](RELEASE-2026-11-06-v0.50.0.md) for the actual source,
1011
companion matrix, archive verification and proof limits. Historical native
1112
measurements belong to their named versions; this checkpoint is not a new run.

‎docs/MANAGED-POLICY-VALIDATION.md‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Claude Code managed-policy preflight — candidate #122
2+
3+
This candidate adds the `claude-code-2.1.296-stdio-files` projection before
4+
Claude Code user-global stdio configuration writes. It addresses a measured
5+
failure in the published 0.50.0 baseline: replacing a denied server command with
6+
a relay command can bypass native exact-command denial, while replacing an
7+
allowed command can make a usable server fail the native allowlist.
8+
9+
## Consumer behavior
10+
11+
Run `mcpm guard enable --client claude-code --server <name> --dry-run` first.
12+
The same preflight runs for the actual wrap and at the adapter write boundary,
13+
including `install`, `update`, `up`, setup through those commands, guard disable,
14+
and toggles. `up --dry-run` preflights before snapshots and secret persistence.
15+
Recognized wrappers are preflighted during enable and preview even when no rewrap
16+
is needed, so a legacy underlying-policy violation is not hidden by an
17+
"already guarded" skip. It compares the current launcher, its recognized underlying command, the proposed
18+
launcher including **every wrapper argument**, and its underlying command.
19+
A denied or unknown proposed launch refuses the transformation; no policy bypass
20+
flag is provided. Exact teardown of a recognized legacy wrapper can restore a
21+
natively denied original command without granting permission. Replacing a denied
22+
old command with a different allowed command is also permitted; the original
23+
decision remains visible. Ordinary direct HTTP writes remain outside this stdio
24+
projection and keep their native URL policy checks. Guard policy refusals exit 1, including dry-run, and count as
25+
errors. They do not create config backups or confinement profiles. Unrelated
26+
servers can still succeed; partial application must be inspected.
27+
28+
Reports contain decisions, rule-list indices and fixed source labels, never raw
29+
commands, argument values, URLs or environment values. An ordinary unmanaged
30+
stdio installation remains usable. A passing projection prints a warning that
31+
overall host policy is **unknown**, including server-managed/MDM/registry/session
32+
and other-project coverage. No managed policy is edited to accommodate a wrapper.
33+
34+
## Supported projection
35+
36+
The fixed source set is user `~/.claude/settings.json`, current-directory
37+
`.claude/settings.json` and `.claude/settings.local.json`, and
38+
`managed-settings.json` plus detection of `managed-mcp.json` under
39+
`/etc/claude-code` on Linux or `/Library/Application Support/ClaudeCode` on macOS.
40+
Reads reject symlink components (selected home/root directory aliases are
41+
canonicalized), non-regular files, invalid UTF-8, duplicate decoded JSON keys,
42+
malformed JSON and files larger than 1 MiB. Descriptor/path metadata changes
43+
during a read produce unknown. Missing files are distinct from unreadable files;
44+
an empty settings file is conservatively unknown rather than an empty allowlist.
45+
46+
Rules contain exactly one `serverName`, `serverCommand` or `serverUrl` key.
47+
For stdio, command matching is an exact command-and-arguments array comparison;
48+
environment blocks do not participate. Deny wins. An unset allowlist is
49+
unrestricted within this file projection; an empty allowlist allows none.
50+
Lists merge across the supported settings scopes, except a managed
51+
`allowManagedMcpServersOnly` lock selects the managed allowlist. A command entry
52+
in the allowlist prevents name-only entries from rescuing stdio commands.
53+
54+
`${VAR}` and `${VAR:-default}` expand without executing anything. Server command
55+
values use the projected live settings environment; policy command values use
56+
the launching environment plus managed settings environment. Deny fallback can
57+
use user settings environment only when a variable is absent from the pinned
58+
environment and has no `:-default`. An explicitly empty startup value stays empty.
59+
Known native-filtered environment references, including color/runtime/credential
60+
controls, are unsupported and produce unknown; this projection does not reproduce
61+
the client's host-dependent environment filter. Missing or unsupported expansion
62+
produces unknown. Names remain literal.
63+
64+
`managed-mcp.json` exclusive control and a matching managed `managedMcpServers`
65+
name prevent rewriting a user-global definition: managed definitions' allowlist
66+
exceptions are not transferred to a user-controlled wrapper. Other managed
67+
provided names do not disable ordinary user-global installations.
68+
69+
## Explicit limits
70+
71+
This is a versioned **file-policy projection**, not a full native policy engine,
72+
host version detection, proof of effective selection, or a runtime enforcement
73+
claim. Native candidate receipts must identify their source/archive/harness and
74+
pinned runtime separately. The Linux native target is Claude Code 2.1.296; no
75+
macOS native result or all-client protection is implied.
76+
77+
A present managed drop-in directory, detected macOS MDM preference file,
78+
Windows/WSL, alternate config directory, ancestor project settings, worktree
79+
root selection, or unsupported local policy/source selector refuses the
80+
transformation as unknown. A top-level `env` in legacy `~/.claude.json` is an
81+
unsupported additional source and refuses stdio transformations. Supported settings
82+
JSON must not contain `policyHelper`, `policyHelpers`, `parentSettings`, `managedSourcesBehavior`, `strictPluginOnlyCustomization` or
83+
`wslInheritsWindowsSettings`; helpers never execute. The plan does not resolve
84+
remote/registry-managed policy, embedding hosts, session flags, other projects,
85+
future host environments or future policy changes. Those planes remain unknown
86+
and are surfaced on successful writes and previews. File reads are snapshots,
87+
not a transaction with a running client or concurrently changing policy tree.
88+
Remote HTTP/SSE matching and managed-policy writes are outside this MVP.
89+
90+
Upgrading does not automatically rewrite existing wrappers, reevaluate their
91+
already-running servers, or add a runtime native-policy enforcement layer. Inspect
92+
existing wrappers with the supported dry-run. Where authorized, exact unwrapping
93+
can restore the original command and its native denial; it does not grant that
94+
command permission to run. Client restart/reload behavior remains separate.
95+
96+
## Evidence
97+
98+
Unit fixtures exercise real shared `replaceServer` and `addServer` write gates,
99+
allow/deny/unset/empty rules, exact wrapper argv, scope merges, managed-only locks,
100+
expansion and pinned environments, managed shadowing/exclusive control, malformed
101+
and duplicate-key sources, unknown source selection, dry-run and confinement
102+
refusal before backup/profile files. Existing scope-reader fixtures remain green.
103+
104+
Astra independently reproduced additional pre-review candidate bypasses through
105+
plural policy-helper settings, native default/fallback precedence, a native-filtered
106+
environment variable, legacy global environment settings and inherited JavaScript
107+
object keys in variable lookup. The fixes refuse
108+
unsupported sources/references, implement the pinned fallback order and require
109+
own-property environment lookup. An attempted
110+
credential-variable probe did not reproduce and is not counted as a finding.
111+
Fresh artifact checks must use the final reviewed bytes, not those vulnerable
112+
pre-review candidate archives.
113+
114+
### Reviewed candidate checkpoint — 2026-11-06
115+
116+
Sol built the slice; Astra independently reviewed and fixed confirmed regressions.
117+
The frozen candidate archive SHA-256 is
118+
`cca729bd4c97881f1a70c2bea458e91377b6de87e8639da156a117255244939d`.
119+
Its package version is the repository placeholder `0.15.0`, not a published release.
120+
The final policy-source SHA-256 is
121+
`2306f09c1f74b9a464d740166444f37814c9677dc96f511857702d49b5561a3d`.
122+
An independent read-back matched all 66 installed JavaScript files and the archive
123+
in each native test image to the reviewed build. Receipts bind all changed source
124+
and test files, root lock, native harness and public usage skill.
125+
126+
- Node 24.20.0: typecheck including tooling, build, 161 test files / 3,789 tests
127+
passed, three skipped. Coverage passed: 91.55% lines, 86.72% branches,
128+
90.28% statements, 86.75% functions; the negative-threshold probe passed.
129+
- Linux arm64 / Node 26.11.1 / Claude Code 2.1.296: **19 native cases passed**.
130+
Seven enable cases cover unmanaged, denied, allowed, name-allowed, empty allow,
131+
expanded deny and allowing both original/wrapper. Five repair/preview cases
132+
cover three legacy teardown states and denied/unmanaged dry-runs. Five review
133+
regressions cover helpers, fallback precedence, filtered variables, legacy env
134+
and inherited keys. Two existing-wrapper audits refuse the unsafe state without
135+
changing it, including dry-run. Native connection claims include actual
136+
`initialize` and `tools/list` traffic from the synthetic peer.
137+
- The same archive passed the modern/legacy direct/guarded protocol matrix:
138+
**87 passed, zero failed, two expected unsupported, four exclusions**. Archive
139+
SRI, all runtime files, raw dependency lock, harness/skill hashes and actual
140+
container boundaries were independently checked.
141+
- Clean-install smoke of this exact local archive passed using the release
142+
dogfood script in supplied-spec mode; this is candidate evidence, not npm
143+
publication.
144+
- Real macOS Seatbelt dogfood passed: positive control, denied synthetic-secret
145+
read, intact stdio, tampered profile refusal and recorded confinement event.
146+
147+
Native containers were offline, read-only, UID 1000, capability-free with
148+
no-new-privileges, disposable state and no host binds or credentials. Native
149+
`mcp get` deliberately executes only the synthetic peer; product configuration
150+
operations did not execute it or expose the synthetic secret in their output.
151+
152+
One concurrent coverage attempt hit an existing five-second smoke-test timeout;
153+
the idle rerun passed without changing tests. One existing-wrapper dry-run probe
154+
incorrectly assumed no guard state existed after deliberately launching that
155+
wrapper; its corrected fresh run compared the pre-operation state and passed.
156+
Both failed attempts are retained and excluded from passing evidence.
157+
158+
Local evidence roots (temporary maintainer receipts, not public distribution):
159+
`/private/tmp/mcpm-policy-122-final-native-20261106` (`identity.json`,
160+
`verified-final.json`), `/private/tmp/mcpm-policy-122-final-audit-dry-v2-20261106`
161+
and `/private/tmp/mcpm-policy-122-final-protocol-20261106/verified-binding.json`.
162+
These are candidate observations, separate from the vulnerable published 0.50.0
163+
baseline and any later actual-published artifact verification. No model/provider
164+
turn, full host-policy coverage or macOS native-client result is claimed.
165+
166+
Semantics checked against official
167+
[managed MCP documentation](https://code.claude.com/docs/en/managed-mcp) and
168+
[settings source documentation](https://code.claude.com/docs/en/settings) on
169+
2026-11-06. These pages describe later versions too; unsupported planes are
170+
reported rather than guessed from the latest documentation.

‎skills/mcpm/SKILL.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: mcpm
33
description: Discover, assess, verify and manage MCP servers with the @getmcpm/cli package. Use when working with mcpm evidence, project locks or AI client MCP configuration.
44
metadata:
5-
version: "1.2.1"
5+
version: "1.2.2"
66
evidence-contract: "mcpm_search, mcpm_info, advertised mcpm_doctor and verify --json schemaVersion 1"
77
---
88

@@ -91,3 +91,20 @@ protection or re-lock unexpected drift just to finish. A transient fetch error
9191
can be retried; persistent unavailable evidence requires investigation. Remote
9292
HTTP/SSE servers do not get the stdio guard's runtime protection. A skill helps
9393
agents follow a workflow; the CLI and guard enforce policy.
94+
95+
## Native policy before wrapping
96+
97+
When the installed CLI supports the Claude Code file-policy preflight, start with
98+
`mcpm guard enable --client claude-code --server <name> --dry-run`. A denied or
99+
unknown file-policy transformation exits 1; stop rather than changing managed
100+
controls or finding a wrapper/argument bypass. The actual shared config write
101+
gate also covers install, update and up. Compare current/original and proposed
102+
launcher decisions; command policies compare every argument, including wrapper
103+
arguments. A successful file projection still reports overall host policy unknown:
104+
server-managed, MDM/registry, session and other-project restrictions are not
105+
resolved. Preserve those unknowns and verify authorized native behavior separately.
106+
107+
Existing recognized wrappers are checked by the supported preview too. An upgrade
108+
does not automatically repair old wrappers or stop running servers. Exact
109+
unwrapping can restore native denial when that repair is authorized; it is not
110+
permission to run the denied command or weaken managed controls.

0 commit comments

Comments
 (0)