Skip to content

MergeSettings prunes bare mcp__<server> permission rules, including deny rules #2483

Open

Description

@jackmaninov

Version

LifeOS 8.761.0 · attestation: not run

What is broken

MergeSettings.permissionRuleRejectionReason rejects the bare MCP server rule mcp__<server>, and prunePermissionRules then deletes it from the generated settings.json. Claude Code documents that form as valid: the permissions page says "mcp__puppeteer matches any tool provided by the puppeteer server" (https://code.claude.com/docs/en/permissions#mcp). The check requires at least three __-separated parts, so it accepts mcp__puppeteer__* but not mcp__puppeteer.

The prune runs on deny and ask as well as allow, so this goes beyond a lost convenience. A user who blocks a whole server with "deny": ["mcp__<server>"] gets a settings.json with that deny rule removed. MergeSettings exits 0 and only prints a stderr warning that calls the rule invalid and says the harness would reject it. The harness wouldn't: it reads the rule and denies every tool from that server. After the merge, nothing is denied.

LIFEOS/TOOLS/MergeSettings.ts in my install is byte-identical to the v8.761.0 tag, and upstream main hasn't touched it since the tag. I've left the clean-tree attestation out because it reports 92 files modified on this install for unrelated reasons, and none of them affect this check. The probe below runs the installed file.

Where (file:line)

LIFEOS/TOOLS/MergeSettings.ts:307

Repro on a clean tree

# from the install root of a v8.761.0 tree
d=$(mktemp -d)
echo '{"permissions":{"allow":["mcp__puppeteer","mcp__puppeteer__*"],"deny":["mcp__dangerous"]}}' > $d/system.json
echo '{}' > $d/user.json
bun LIFEOS/TOOLS/MergeSettings.ts --system $d/system.json --user $d/user.json --output $d/out.json
echo "exit $?"        # 0
cat $d/out.json       # allow: ["mcp__puppeteer__*"], deny: [] (both bare-server rules are gone)
# expected: both bare-server rules survive, since Claude Code defines that form

Negative control

Probe: d=$(mktemp -d); printf %s "{\"permissions\":{\"allow\":[\"mcp__puppeteer\",\"mcp__puppeteer__*\"],\"deny\":[\"mcp__dangerous\"]}}" > $d/system.json; echo "{}" > $d/user.json; bun ${RELEASE_TREE:-.}/LIFEOS/TOOLS/MergeSettings.ts --system $d/system.json --user $d/user.json --output $d/out.json; cat $d/out.json; grep -q "\"mcp__dangerous\"" $d/out.json · exit 1 · captured 2026-10-30T20:10:58.433Z

Merged 1 system keys + 0 user overlays into /tmp/tmp.YhZBVBv0p8/out.json
{
  "permissions": {
    "allow": [
      "mcp__puppeteer__*"
    ],
    "deny": []
  }
}

--- stderr ---
⚠️  MergeSettings pruned 2 invalid permission rule(s) the harness would reject:
   - permissions.allow: mcp__puppeteer  (mcp wildcard "mcp__puppeteer" is not in tool position — globs are only allowed after a literal mcp__<server>__ prefix)
   - permissions.deny: mcp__dangerous  (mcp wildcard "mcp__dangerous" is not in tool position — globs are only allowed after a literal mcp__<server>__ prefix)
   Fix these at the SOURCE (settings.system.json / settings.user.json) so the warning clears.

Suggested fix

Drop the parts.length < 3 condition. The rule is only invalid when the server segment is empty or contains a glob, which still rejects mcp__, mcp__*, mcp__pup* and mcp__*__navigate, the unanchored forms Claude Code itself skips. The attached diff makes that one-line change and adds LIFEOS/TOOLS/MergeSettings.test.ts with 4 cases. On v8.761.0, 2 of the 4 fail (the bare-server form, and a deny rule surviving the prune). With the patch, all 4 pass. The patch applies with git apply at the install root of a v8.761.0 checkout. "Verified after fix" below is the same probe, run with RELEASE_TREE pointing at a v8.761.0 copy with the patch applied.

diff --git a/LIFEOS/TOOLS/MergeSettings.test.ts b/LIFEOS/TOOLS/MergeSettings.test.ts
new file mode 100644
index 0000000..c18a309
--- /dev/null
+++ b/LIFEOS/TOOLS/MergeSettings.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from "bun:test";
+import { permissionRuleRejectionReason, prunePermissionRules } from "./MergeSettings";
+
+describe("mcp permission rule shape", () => {
+  test("bare mcp__<server> is valid (Claude Code: matches every tool from that server)", () => {
+    expect(permissionRuleRejectionReason("mcp__puppeteer")).toBeNull();
+    expect(permissionRuleRejectionReason("mcp__plugin_my-plugin_database-tools")).toBeNull();
+  });
+
+  test("server-wide glob and single-tool forms stay valid", () => {
+    expect(permissionRuleRejectionReason("mcp__puppeteer__*")).toBeNull();
+    expect(permissionRuleRejectionReason("mcp__github__get_*")).toBeNull();
+    expect(permissionRuleRejectionReason("mcp__puppeteer__puppeteer_navigate")).toBeNull();
+  });
+
+  test("a glob or empty name in the server position is still rejected", () => {
+    expect(permissionRuleRejectionReason("mcp__")).not.toBeNull();
+    expect(permissionRuleRejectionReason("mcp__*")).not.toBeNull();
+    expect(permissionRuleRejectionReason("mcp__pup*")).not.toBeNull();
+    expect(permissionRuleRejectionReason("mcp__*__navigate")).not.toBeNull();
+  });
+
+  test("a bare-server deny rule survives the prune", () => {
+    const settings = { permissions: { allow: ["mcp__puppeteer"], deny: ["mcp__dangerous"], ask: [] } };
+    expect(prunePermissionRules(settings)).toEqual([]);
+    expect(settings.permissions.deny).toEqual(["mcp__dangerous"]);
+    expect(settings.permissions.allow).toEqual(["mcp__puppeteer"]);
+  });
+});
diff --git a/LIFEOS/TOOLS/MergeSettings.ts b/LIFEOS/TOOLS/MergeSettings.ts
index 4e673d8..1b57dd6 100755
--- a/LIFEOS/TOOLS/MergeSettings.ts
+++ b/LIFEOS/TOOLS/MergeSettings.ts
@@ -302,9 +302,10 @@ export function permissionRuleRejectionReason(rule: string): string | null {
 
   if (rule.startsWith("mcp__")) {
     const parts = rule.split("__");
-    // Valid shape: mcp__<server>__<tool-or-glob> => at least 3 parts, server
-    // segment non-empty and not itself a wildcard.
-    if (parts.length < 3 || parts[1].length === 0 || parts[1].includes("*")) {
+    // Valid shapes: mcp__<server> (every tool from that server, per the Claude
+    // Code permissions docs) or mcp__<server>__<tool-or-glob>. Either way the
+    // server segment must be non-empty and not itself a wildcard.
+    if (parts[1].length === 0 || parts[1].includes("*")) {
       return `mcp wildcard "${rule}" is not in tool position — globs are only allowed after a literal mcp__<server>__ prefix`;
     }
     return null;

Verified after fix

Probe: d=$(mktemp -d); printf %s "{\"permissions\":{\"allow\":[\"mcp__puppeteer\",\"mcp__puppeteer__*\"],\"deny\":[\"mcp__dangerous\"]}}" > $d/system.json; echo "{}" > $d/user.json; bun ${RELEASE_TREE:-.}/LIFEOS/TOOLS/MergeSettings.ts --system $d/system.json --user $d/user.json --output $d/out.json; cat $d/out.json; grep -q "\"mcp__dangerous\"" $d/out.json · exit 0 · captured 2026-10-30T20:10:58.485Z

Merged 1 system keys + 0 user overlays into /tmp/tmp.cGIIC8gDzX/out.json
{
  "permissions": {
    "allow": [
      "mcp__puppeteer",
      "mcp__puppeteer__*"
    ],
    "deny": [
      "mcp__dangerous"
    ]
  }
}

Environment

  • OS: Pop!_OS 24.04 LTS (x64)
  • Harness: claude-code
  • bun: 1.3.14 · git: 2.56.0
  • Doctor broken: none

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions