Repository navigation
MergeSettings prunes bare mcp__<server> permission rules, including deny rules #2483
Description
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 formNegative 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