Project-level permissions replace global permissions instead of merging

Status Open
Maintainer reply None cached
Activity 14 comments · opened Jan 9, 2026

Description

When a project has a local .claude/settings.local.json file with a permissions block, it appears to replace the global permissions from ~/.claude/settings.local.json rather than merging with them.

Expected Behavior

Global and local permissions should merge:

  • Global allow: ["mcp__Ref__ref_search_documentation", "mcp__Ref__ref_read_url", "WebSearch", ...]
  • Local allow: ["mcp__expo-mcp__search_documentation"]
  • Effective allow: Union of both lists

Actual Behavior

Local permissions replace global:

  • Tools allowed globally (e.g., mcp__Ref__ref_read_url) still prompt for permission when a local permissions block exists but doesn't include them
  • Users must duplicate all global permissions in every project's local settings

Steps to Reproduce

  1. Add "mcp__Ref__ref_read_url" to global ~/.claude/settings.local.json permissions allow list
  2. Create a project with .claude/settings.local.json that has a permissions block but doesn't include that tool
  3. Try to use the Ref tool - it will prompt for permission despite being globally allowed

Impact

This defeats the purpose of global settings. Users expect global permissions to apply everywhere unless explicitly overridden locally.

Suggested Fix

Merge permission arrays (union for allow, union for deny) rather than replacing them entirely when local settings exist.

---
🤖 Generated with Claude Code

View original on GitHub ↗

14 Comments

github-actions[bot] · 7 months ago

Found 2 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/5140
  2. https://github.com/anthropics/claude-code/issues/16728

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

jwalls89 · 7 months ago

I'm finding the same issue

anderewrey · 7 months ago

same here.

Official Claude Code documentation mentions there's "merge" of the settings.
https://code.claude.com/docs/en/settings

Precedence: Higher-level configurations (Managed) override lower-level ones (User/Project)

Inheritance: Settings are merged, with more specific settings adding to or overriding broader ones

So the documentation must be adjusted or allowing it correctly!

kurt-yoo · 7 months ago

I'm experiencing the same issue, but it's not limited to just permissions - configuration is also being completely overridden instead of merged.

Setup:

  • Global config (): Has configured
  • Project config (): Has configured, but no

Expected behavior:

  • Project should use its own settings
  • Project should inherit from global config (since it's not defined locally)

Actual behavior:

  • Project work correctly
  • is completely ignored - it doesn't show up at all

Workaround:
I have to duplicate the configuration in every project's , which defeats the purpose of having global defaults.

This issue started happening after updating Claude Code with curl -fsSL https://claude.ai/install.sh | bash. The previous version worked fine with setting inheritance.

It seems like any setting field (not just permissions) gets completely replaced instead of merged when a project config file exists. This is a significant regression in usability.

AnotherJob · 7 months ago

Try renaming your "settings.local.json" to "settings.json".

Details

I hit this too. But I notice you're talking about a file called settings.local.json. The documentation talks about ~/.claude/settings.json (no ".local."). Why did you create a "settings.local.json" file there?

In my case, I think what happened is that moved a project-level "settings.local.json" file that claude had created, to my ~/.claude/. The claude CLI's behavior - of creating a settings.local.json in whatever subdirectory I happen to be working in - is a poor way of saving my preferences (I work on multiple projects, using multiple machines).

I've been experimenting with various scenarios and found that merging works for both "allow" and "deny" permissions. Maybe some other combinations still don't work. But since I stopped using "settings.local.json" I haven't hit a repro (yet).

jason-engage · 7 months ago

This is insanely annoying. I've added every freaking permission into allow in ~/.claude/settings.json, I have 'accept edits on', and it still asks my permission for everything. And when it adds its own permissions for the project it automatically adds them to settings.local.json, so everyone saying to rename this file to settings.json is just avoiding the issue. Is this a merging issue or just a crazy permissions bug?

and when i check /permissions - all the permissions i set globally in ~/../settings.json are there! Still asks so many permission questions.

AnotherJob · 6 months ago

I wasn't suggesting to rename the project-level settings.local.json. I was talking specifically about a user-level one - that is not supported, but is an easy situation to end up in. And, it is what this bug report is reporting.

Once I cleaned that up, and after testing very carefully, I realized I could no longer repro the reported problem. I say "very carefully" because there are other very similar, very annoying bugs/design problems here that have nothing to do with the merging behavior:

  1. There is a very similar UI for "directory access" permissions, that also has a "don't ask me again" but what that one does is only remember it for the current session - it doesn't write it to any file.
  2. After you grant it permission to do something and retry it to test, you have to pay close attention to whether it used the same command the second time. Claude tends to use different commands to do the same task.
  3. There's no provision for user-specific permissions that are shared across machines. We have to build that ourselves.
  4. Claude saves at the current working directory level, not repo root - So you get .claude/settings.local.json scattered in deep subdirectories
  5. The automated generation of settings.local.json is also project-specific. Trying to work around that, is what led me into this "bug" (where in fact it's an annoying, "by design" issue. The documentation doesn't say Claude supports a "~/.claude/settings.local.json" file.)
jason-engage · 6 months ago

I set them up like this:

...
"Bash(lsof:*)",
"Bash(mkdir:*)",
"Bash(mudslide:*)",
"Bash(mv:./**)",
"Bash(node:*)",
"Bash(npm:*)",
"Bash(npm create:*)",
"Bash(npm install:*)",
"Bash(npx: *)",
"Bash(npx tsx:*)",
"Bash(open:*)",
"Bash(pip:*)",
"Bash(ps:*)",
"Bash(pgrep:*)",
"Bash(python:*)",
"Bash(python3:*)",
"Bash(rm:./**)",
"Bash(scp:./**)",
"Bash(ssh:*)",
"Bash(stat:*)",
"Bash(tail:*)",
"Bash(touch:*)",
...

I use * to catch all variations. Maybe it works for some commands, but not all, because it asks me for permissions alot

all of these were added when you answer "Dont ask me again for this project"

 "Bash(npx untitledui-cli@latest init)",
  "Bash(npx untitledui@latest init . --vite)",
  "Bash(npx untitledui@latest init:*)",
  "Bash(npx untitledui@latest add button)",
  "Bash(npx untitledui@latest add button:*)",
  "Bash(npx untitledui@latest add input card --path app/components/ui)",
  "Bash(npx untitledui@latest add:*)",

....

**But I have this permission in my settings.json: "Bash(npx: )",*

So there should be no need for these:

"Bash(npx untitledui@latest init . --vite)",
"Bash(npx untitledui@latest init:*)",
"Bash(npx untitledui@latest add button)",
"Bash(npx untitledui@latest add button:*)",

Am I setting the catch all's wrong?

is it Bash(npx: *) or Bash(npx *) or Bash(npx*) or Bash(npx: **) ??

We need better permission docs, way more examples, and even claude is unsure sometimes when you ask it.

AnotherJob · 6 months ago

Yes, almost every time I asked Claude to help, it first formed the theory that ":*" is invalid syntax. But that's a hallucination. (Claude did eventually help me understand the cases I investigated, with a lot of hand-holding).

Bash(npx:*) works fine. But the problem is that Claude doesn't always emit a straight Bash(npx ...) command. e.g. sometimes it does Bash(cd c:\myproject && npx ...), which doesn't match "Bash(npx:*)". For some commands it throws another "bash" in there - like Bash(bash foo). And there are probably more variations I haven't noticed/encountered yet.

jason-engage · 6 months ago

I think I fixed it by switching all the rules to this style:


"Bash(curl *)",
"Bash(diff *)",
"Bash(echo *)",
"Bash(file *)",
"Bash(find *)",
"Bash(gh *)",
"Bash(git *)",
"Bash(grep *)",
"Bash(head *)",
"Bash(kill *)",
"Bash(locate *)",
"Bash(ln *)",
"Bash(ls *)",
"Bash(lsof *)",
"Bash(mkdir *)",
"Bash(mudslide *)",
"Bash(mv ./**)",
"Bash(node *)",
"Bash(npm *)",
"Bash(npx *)",

I think using the ":" syntax is wrong or maybe i was doing it wrong without the space between the colon and the star.

jspiro · 6 months ago

Workaround: SessionStart hook that merges global permissions into each project.

<details>
<summary>~/.claude/hooks/sync-permissions.py</summary>

#!/usr/bin/env python3
"""Syncs global permissions to project-level settings."""
import json
import sys
from pathlib import Path

GLOBAL_SETTINGS = Path.home() / ".claude" / "settings.json"
PROJECT_SETTINGS = Path(".claude") / "settings.local.json"
SYNC_MARKER_KEY = "_syncedFromGlobal"

def load_json(path: Path) -> dict:
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text())
    except (json.JSONDecodeError, IOError):
        return {}

def merge_permissions(global_perms: dict, local_perms: dict) -> dict:
    merged = {}
    for key in ["deny", "ask", "allow"]:
        combined = list(local_perms.get(key, []))
        for item in global_perms.get(key, []):
            if item not in combined:
                combined.append(item)
        if combined:
            merged[key] = combined
    return merged

def main():
    global_perms = load_json(GLOBAL_SETTINGS).get("permissions", {})
    if not global_perms:
        sys.exit(0)
    
    global_hash = str(hash(json.dumps(global_perms, sort_keys=True)))
    project_settings = load_json(PROJECT_SETTINGS)
    
    if project_settings.get(SYNC_MARKER_KEY) == global_hash:
        sys.exit(0)  # Already synced
    
    project_settings["permissions"] = merge_permissions(
        global_perms, project_settings.get("permissions", {}))
    project_settings[SYNC_MARKER_KEY] = global_hash
    
    PROJECT_SETTINGS.parent.mkdir(parents=True, exist_ok=True)
    PROJECT_SETTINGS.write_text(json.dumps(project_settings, indent=2) + "\n")
    sys.exit(0)

if __name__ == "__main__":
    try:
        main()
    except Exception:
        sys.exit(0)  # Do not block session on errors

</details>

Hook config (~/.claude/settings.json):

{
  "hooks": {
    "SessionStart": [{"type": "command", "command": "~/.claude/hooks/sync-permissions.py"}],
    "UserPromptSubmit": [{"type": "command", "command": "~/.claude/hooks/sync-permissions.py"}],
    "PreToolUse": [{"type": "command", "command": "~/.claude/hooks/sync-permissions.py"}]
  }
}

Running on multiple hooks ensures permissions sync even if global settings change mid-session. Would also benefit from running on SubagentStart once that hook is fully supported (#19170).

yurukusa · 5 months ago

Workaround — hooks are additive, not replaceable:

Unlike permission rules (where project can replace global), hooks in ~/.claude/settings.json always fire regardless of project-level settings. They're the one mechanism that truly provides global enforcement.

npx cc-safe-setup

Writes 8 safety hooks to user-level ~/.claude/settings.json. These fire for every project, every agent, every tool call — project-level settings can't disable them.

mvillsley · 4 months ago

Still happening — adding a current confirming repro on Windows:

  • Global ~/.claude/settings.json permissions.allow contains Read(//c/REPOS/**).
  • Project .claude/settings.local.json has its own permissions.allow block listing unrelated rules (no Read rule).
  • Asking the model to read a file under c:/REPOS/... fires a permission prompt despite the global rule covering it.
  • Choosing "Always allow → project shared" writes a narrower duplicate (Read(//c/REPOS/.../WebConsole/**)) into project .claude/settings.json, which becomes the permanent residue.

Same symptom for additionalDirectories: a directory present in global additionalDirectories is re-prompted when the project settings file has its own additionalDirectories array.

Net effect: any project that adds even one project-level permission rule effectively forfeits the entire global allow-list.

Environment: Windows 11 Enterprise, Claude Code VS Code extension.

piercephilip981-glitch · 1 month ago

Confirming this is still happening in the CLI (not just the desktop app) as of v2.1.206/v2.1.207 (2026-07-09/07-10).

Repro: global ~/.claude/settings.json has permissions.defaultMode: "bypassPermissions". Any project whose .claude/settings.json has its own permissions.allow array (auto-populated over time via "don't ask again" clicks) — with no defaultMode key of its own — loses the global bypass mode entirely. Bash/Edit/etc. calls in that project prompt for approval again despite bypass being set globally.

Audited 38 project-level settings.json files on one machine: 36 had a project-level permissions block with no defaultMode, all exposed to this. So this isn't an edge case — it's the default outcome of normal usage (clicking "don't ask again" once in a project is enough to trigger it).

Workaround for now: explicitly set "defaultMode": "bypassPermissions" in each project's .claude/settings.json alongside its allow list. That confirms the mechanism — it's not that the global default fails to apply in general, it's that presence of any project-level permissions object fully overrides rather than merges with the global one.

+1 for the merge-not-replace fix suggested above.