Skip to content

Commit b3cc859

Browse files
authored
fix: preserve Claude policy preflight compatibility (#325)
1 parent 538bb61 commit b3cc859

9 files changed

Lines changed: 87 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ published section (it happened to #170).
1212

1313
### Fixed
1414

15+
- Keep Claude Code policy checks usable for projects beneath home by recognizing the existing user settings source once. Restore explicit initialization of empty current client configs while retaining strict policy-file parsing and exact original-byte backups.
16+
1517
- 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.
1618

1719
### Documentation

‎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:** 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
68+
**Status:** built by Sol and reviewed/fixed by Astra; merged in PR #324 but not released. The pre-follow-up candidate passed 19 native cases and 87 protocol comparisons. The home-ancestor and empty-current-config follow-up candidate is verified and Astra reviewed: 22 native cases, three packed initialization cases and 87 protocol checks passed. PR/CI, merge and release are pending. 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/MAINTAINER-WORKFLOW.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
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-
**#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
7+
**#122 managed-policy-aware setup** merged in PR #324 but remains unreleased.
8+
The compatibility follow-up candidate is verified and Astra reviewed; PR/CI,
9+
merge and release are pending. Its [candidate validation](MANAGED-POLICY-VALIDATION.md)
10+
separates the earlier archive checkpoint from the verified follow-up and records
11+
the bounded profile and limits.
12+
See the [active backlog](BACKLOG.md) and
1013
[v0.50.0 release evidence](RELEASE-2026-11-06-v0.50.0.md) for the actual source,
1114
companion matrix, archive verification and proof limits. Historical native
1215
measurements belong to their named versions; this checkpoint is not a new run.

‎docs/MANAGED-POLICY-VALIDATION.md‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ canonicalized), non-regular files, invalid UTF-8, duplicate decoded JSON keys,
4242
malformed JSON and files larger than 1 MiB. Descriptor/path metadata changes
4343
during a read produce unknown. Missing files are distinct from unreadable files;
4444
an empty settings file is conservatively unknown rather than an empty allowlist.
45+
Only current user-global client-config reads accept empty/whitespace content as
46+
an empty object, retaining mcpm's explicit-initialization behavior and backing up
47+
the original bytes before a write. This is not native JSON-validity evidence:
48+
Claude Code 2.1.296 reports such input as corrupted and performs recovery. The
49+
strict default remains unchanged for policy files and the scope inventory.
4550

4651
Rules contain exactly one `serverName`, `serverCommand` or `serverUrl` key.
4752
For stdio, command matching is an exact command-and-arguments array comparison;
@@ -77,7 +82,10 @@ macOS native result or all-client protection is implied.
7782
A present managed drop-in directory, detected macOS MDM preference file,
7883
Windows/WSL, alternate config directory, ancestor project settings, worktree
7984
root selection, or unsupported local policy/source selector refuses the
80-
transformation as unknown. A top-level `env` in legacy `~/.claude.json` is an
85+
transformation as unknown. The already-modeled `~/.claude/settings.json` source
86+
is not counted again as an unsupported ancestor when the project lives beneath
87+
home; other ancestor settings, including `~/.claude/settings.local.json`, still
88+
refuse. A top-level `env` in legacy `~/.claude.json` is an
8189
unsupported additional source and refuses stdio transformations. Supported settings
8290
JSON must not contain `policyHelper`, `policyHelpers`, `parentSettings`, `managedSourcesBehavior`, `strictPluginOnlyCustomization` or
8391
`wslInheritsWindowsSettings`; helpers never execute. The plan does not resolve
@@ -111,7 +119,7 @@ credential-variable probe did not reproduce and is not counted as a finding.
111119
Fresh artifact checks must use the final reviewed bytes, not those vulnerable
112120
pre-review candidate archives.
113121

114-
### Reviewed candidate checkpoint — 2026-11-06
122+
### Reviewed pre-follow-up candidate checkpoint — 2026-11-06
115123

116124
Sol built the slice; Astra independently reviewed and fixed confirmed regressions.
117125
The frozen candidate archive SHA-256 is
@@ -163,6 +171,27 @@ These are candidate observations, separate from the vulnerable published 0.50.0
163171
baseline and any later actual-published artifact verification. No model/provider
164172
turn, full host-policy coverage or macOS native-client result is claimed.
165173

174+
### Compatibility follow-up — release pending
175+
176+
PR #324 merged the reviewed implementation, but no release was published. A later
177+
review found two compatibility regressions: projects below home misclassified
178+
user settings as an unsupported ancestor, and empty current-config initialization
179+
was rejected. Both have shared-adapter regressions and narrowly scoped fixes. Node 24.20.0
180+
typecheck including tooling, build and the full suite pass: 161 files, 3,793 tests
181+
passed and three skipped. The archive and native/protocol checkpoint above precede these fixes and are not
182+
proof of the follow-up artifact. Fresh follow-up archive
183+
`a08c4f6347c4b4d812ebfe93f041ca0b59e62c9722f4a2fc37e514561927caf8`
184+
passed 22 native policy cases plus three packed initialization cases (empty,
185+
whitespace and strict empty-policy refusal), and 87 protocol checks with zero
186+
failures, two expected unsupported and four exclusions. All 66 installed JS files,
187+
archive, lock, harness/skill hashes and actual container boundaries were
188+
independently verified. Exact-archive clean-install smoke, coverage (91.56% lines,
189+
86.72% branches) and the negative-threshold gate passed. Receipts:
190+
`/private/tmp/mcpm-policy-122-compat-final-native-20261106/verified-final.json`
191+
and `/private/tmp/mcpm-policy-122-compat-final-protocol-20261106/verified-binding.json`.
192+
These remain candidate checks; publication verification is separate. The empty-config native probe observed corrupted-JSON recovery, not clean
193+
native acceptance; compatible mcpm initialization is a separate behavior.
194+
166195
Semantics checked against official
167196
[managed MCP documentation](https://code.claude.com/docs/en/managed-mcp) and
168197
[settings source documentation](https://code.claude.com/docs/en/settings) on

‎src/__tests__/config/adapters/claude-policy.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,35 @@ beforeEach(async () => {
3131
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
3232

3333
describe("Claude Code file policy preflight", () => {
34+
it("models user settings once when the project lives below home and still refuses other ancestors", async () => {
35+
const project = path.join(home, "repos", "project");
36+
await mkdir(project, { recursive: true });
37+
await settings({ allowedMcpServers: [{ serverName: "fixture" }] }, "user");
38+
const adapter = new ClaudeCodeAdapter({ ...context(), root: project });
39+
await adapter.replaceServer(config, "fixture", wrapped());
40+
expect(JSON.parse(await readFile(config, "utf8")).mcpServers.fixture).toEqual(wrapped());
41+
await settings({ deniedMcpServers: [{ serverCommand: ["node", "fixture.js"] }] }, "user");
42+
await expect(adapter.replaceServer(config, "fixture", wrapped())).rejects.toThrow(/underlying.*denied/i);
43+
await settings({}, "user");
44+
const ancestor = path.join(home, "repos", ".claude");
45+
await mkdir(ancestor);
46+
await writeFile(path.join(ancestor, "settings.json"), "{}");
47+
await expect(adapter.replaceServer(config, "fixture", wrapped())).rejects.toThrow(/Ancestor project settings/);
48+
await rm(ancestor, { recursive: true });
49+
await writeFile(path.join(home, ".claude", "settings.local.json"), "{}");
50+
await expect(adapter.replaceServer(config, "fixture", wrapped())).rejects.toThrow(/Ancestor project settings/);
51+
});
52+
it.each(["", " \t\r\n"])("initializes an empty current config while retaining strict empty policy handling (%j)", async raw => {
53+
await writeFile(config, raw);
54+
const adapter = new ClaudeCodeAdapter(context());
55+
expect(await adapter.read(config)).toEqual({});
56+
await adapter.addServer(config, "fixture", original);
57+
expect(JSON.parse(await readFile(config, "utf8")).mcpServers.fixture).toEqual(original);
58+
expect(await readFile(`${config}.bak`, "utf8")).toBe(raw);
59+
await settings({});
60+
await writeFile(path.join(managed, "managed-settings.json"), raw);
61+
await expect(adapter.replaceServer(config, "fixture", wrapped())).rejects.toThrow(/managed policy malformed/);
62+
});
3463
it("reproduces original-command deny bypass through replaceServer, before any writes", async () => {
3564
await settings({ deniedMcpServers: [{ serverCommand: ["node", "fixture.js"] }] });
3665
const before = await readFile(config, "utf8");

‎src/__tests__/config/read-bounded-json.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ it("classifies absent, unreadable, oversized and symlink files without returning
2222
const link = path.join(dir, "link.json"); await symlink(file, link);
2323
expect((await readConfig(link)).state).toBe("symlink-skipped");
2424
});
25+
it("limits empty initialization to the opted-in current-config read without weakening JSON validation", async () => {
26+
await writeFile(file, " \t\r\n");
27+
expect(await readConfig(file)).toEqual({ state: "malformed" });
28+
expect(await readConfig(file, { allowEmpty: true })).toEqual({ state: "present", value: {} });
29+
await writeFile(file, Buffer.from([0xff]));
30+
expect(await readConfig(file, { allowEmpty: true })).toEqual({ state: "malformed" });
31+
await writeFile(file, '{"mcpServers":{},"mcpServers":{}}');
32+
expect(await readConfig(file, { allowEmpty: true })).toEqual({ state: "malformed" });
33+
await writeFile(file, " ".repeat(1024 * 1024 + 1));
34+
expect(await readConfig(file, { allowEmpty: true })).toEqual({ state: "too-large" });
35+
});
2536
it("rejects changes during a read rather than returning a clean snapshot", async () => {
2637
await writeFile(file, "{}");
2738
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");

‎src/config/adapters/claude-code.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class ClaudeCodeAdapter extends BaseAdapter {
2626

2727
protected override async readRaw(configPath: string): Promise<Record<string, unknown>> {
2828
const directory = await realpath(path.dirname(configPath)).catch(() => path.dirname(configPath));
29-
const read = await readConfig(path.join(directory, path.basename(configPath)));
29+
const read = await readConfig(path.join(directory, path.basename(configPath)), { allowEmpty: true });
3030
if (read.state !== "present" && read.state !== "missing") {
3131
throw new Error(`Claude Code file policy unknown: Current configuration ${read.state}`);
3232
}

‎src/config/claude-policy.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ export async function planClaudePolicyChange(configPath: string, name: string, p
118118
];
119119
for (let ancestor = path.dirname(root); ; ancestor = path.dirname(ancestor)) {
120120
for (const name of ["settings.json", "settings.local.json"]) {
121-
const state = await inspect(path.join(ancestor, ".claude", name));
121+
const candidate = path.join(ancestor, ".claude", name);
122+
if (candidate === files[0].path) continue;
123+
const state = await inspect(candidate);
122124
if (state !== "missing") return refuse("Ancestor project settings require unsupported root/selection resolution");
123125
}
124126
if (ancestor === path.dirname(ancestor)) break;
@@ -145,7 +147,7 @@ export async function planClaudePolicyChange(configPath: string, name: string, p
145147
if (exclusive.state !== "missing") return refuse(exclusive.state === "present" ? "managed-mcp exclusive control: user-global definitions are not selected" : `managed-mcp policy ${exclusive.state}`, exclusive.state === "present" ? "blocked" : "unknown");
146148
if (managedSettings.managedMcpServers && Object.hasOwn(managedSettings.managedMcpServers, name)) return refuse("managedMcpServers supplies this name; user-global definition is shadowed", "blocked");
147149
const configDirectory = await realpath(path.dirname(configPath)).catch(() => path.dirname(configPath));
148-
const current = await readConfig(path.join(configDirectory, path.basename(configPath)));
150+
const current = await readConfig(path.join(configDirectory, path.basename(configPath)), { allowEmpty: true });
149151
if (current.state !== "missing" && current.state !== "present") return refuse(`Current configuration ${current.state}`);
150152
if (current.value?.env !== undefined) return refuse("Legacy global configuration environment is outside the supported profile");
151153
const serverMap = current.value?.mcpServers;

‎src/config/read-bounded-json.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function duplicateKeys(node: Node): boolean {
3838
return node.children?.some(duplicateKeys) ?? false;
3939
}
4040

41-
export async function readConfig(file: string): Promise<{ state: FileState; value?: Record<string, unknown> }> {
41+
export async function readConfig(file: string, options: { allowEmpty?: boolean } = {}): Promise<{ state: FileState; value?: Record<string, unknown> }> {
4242
const state = await inspect(file);
4343
if (state !== "present") return { state };
4444
try {
@@ -64,6 +64,7 @@ export async function readConfig(file: string): Promise<{ state: FileState; valu
6464
const bytes = buffer.subarray(0, length);
6565
if (!isUtf8(bytes)) return { state: "malformed" };
6666
const raw = bytes.toString("utf8");
67+
if (options.allowEmpty && raw.trim() === "") return { state: "present", value: {} };
6768
const errors: ParseError[] = [];
6869
const tree = parseTree(raw, errors, { disallowComments: true, allowTrailingComma: false });
6970
if (!tree || errors.length || duplicateKeys(tree)) return { state: "malformed" };

0 commit comments

Comments
 (0)