[BUG] Bash permissions in settings.json not enforced - requires custom hook workaround

Status Fixed / completed
Maintainer reply None cached
Activity 12 comments · opened Jan 17, 2026 · closed Jul 21, 2026

Bug Report: Bash Permissions in settings.json Not Respected

Summary

The permissions.allow and permissions.deny rules for Bash commands in settings.json are not reliably enforced. Users must create custom PreToolUse hooks to achieve the permission behavior that the configuration system promises.

Environment

  • Claude Code Version: Latest (check with claude --version)
  • OS: macOS (Darwin 25.2.0)
  • Interface: CLI (Terminal)

Steps to Reproduce

  1. Create ~/.claude/settings.json with Bash permissions:
{
  "permissions": {
    "allow": [
      "Bash(mkdir:*)",
      "Bash(ls:*)",
      "Bash(git status:*)"
    ],
    "deny": [
      "Bash(rm:*)"
    ]
  }
}
  1. Start a new Claude Code session
  2. Ask Claude to run mkdir -p /tmp/test
  3. Expected: Command runs without permission prompt
  4. Actual: Permission prompt appears despite the allow rule

Expected Behavior

Commands matching patterns in permissions.allow should execute without prompting.
Commands matching patterns in permissions.deny should be blocked automatically.

Actual Behavior

  • Allow rules are ignored for Bash commands
  • Users are prompted for every Bash command regardless of configuration
  • This forces users to either:
  • Use --dangerously-skip-permissions (unsafe)
  • Click "Allow" hundreds of times per session
  • Implement custom PreToolUse hooks (workaround I had to use)

Related Issues

  • #15921: VSCode Extension doesn't respect Bash/Write/Edit permissions at all
  • #13340: Piped commands bypass permission allowlist

Impact

This is a high-friction issue that significantly degrades the user experience:

  1. Productivity loss: Users spend time approving commands that should be auto-approved
  2. Workaround complexity: Users must write custom Python hooks to get basic functionality
  3. Documentation mismatch: The settings.json documentation implies this should work
  4. Trust erosion: Users lose confidence in the permission system

Workaround

I created a PreToolUse hook that properly enforces the allow/deny rules:

#!/usr/bin/env python3
"""PreToolUse hook that enforces Bash permissions from settings.json."""
import json
import sys
from pathlib import Path

def load_settings():
    settings_file = Path.home() / ".claude" / "settings.json"
    allow_patterns = []
    deny_patterns = []

    if settings_file.exists():
        with open(settings_file, 'r') as f:
            settings = json.load(f)
            permissions = settings.get("permissions", {})

            for rule in permissions.get("allow", []):
                if rule.startswith("Bash(") and rule.endswith(")"):
                    allow_patterns.append(rule[5:-1])

            for rule in permissions.get("deny", []):
                if rule.startswith("Bash(") and rule.endswith(")"):
                    deny_patterns.append(rule[5:-1])

    return allow_patterns, deny_patterns

def pattern_matches(pattern, command):
    command = command.strip()
    if pattern.endswith(":*"):
        return command.startswith(pattern[:-2])
    if pattern.endswith(" *"):
        return command.startswith(pattern[:-2])
    return command.startswith(pattern)

def main():
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError:
        sys.exit(0)

    if input_data.get("tool_name") != "Bash":
        sys.exit(0)

    command = input_data.get("tool_input", {}).get("command", "")
    allow_patterns, deny_patterns = load_settings()

    # Deny takes priority
    for pattern in deny_patterns:
        if pattern_matches(pattern, command):
            print(json.dumps({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": f"Denied: {pattern}"
                }
            }))
            sys.exit(0)

    # Check allow
    for pattern in allow_patterns:
        if pattern_matches(pattern, command):
            print(json.dumps({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "allow",
                    "permissionDecisionReason": f"Allowed: {pattern}"
                }
            }))
            sys.exit(0)

    sys.exit(0)

if __name__ == "__main__":
    main()

Suggested Fix

The built-in permission system should:

  1. Actually enforce the allow/deny rules in settings.json for Bash commands
  2. Handle piped commands by checking each component (as noted in #13340)
  3. Work consistently across CLI and VSCode extension
  4. Document limitations clearly if certain patterns can't be supported

Additional Context

This user had 800+ Bash command patterns meticulously configured in settings.json, expecting them to work as documented. The discovery that none of them were being enforced was extremely frustrating and time-consuming to debug.

---

Submitted by: User via Claude Code session
Date: 2026-01-17

View original on GitHub ↗

11 Comments

github-actions[bot] · 7 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/18160
  2. https://github.com/anthropics/claude-code/issues/17321
  3. https://github.com/anthropics/claude-code/issues/13340

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

lucasmccomb · 7 months ago

Update: Same Issue Affects Edit and Write Tools

This bug isn't limited to Bash commands — Edit and Write tool permissions are also not enforced.

Reproduction

  1. Add Edit/Write permissions to settings.json:
{
  "permissions": {
    "allow": [
      "Edit(/Users/me/code/**)",
      "Write(/Users/me/code/**)",
      "Edit(/tmp/**)"
    ]
  }
}
  1. Ask Claude to edit a file in ~/code/
  2. Expected: Edit proceeds without prompt
  3. Actual: Permission prompt still appears

Workaround: PreToolUse Hook for Edit/Write

I extended the hook approach to cover file operations:

#!/usr/bin/env python3
"""PreToolUse hook that auto-approves Edit and Write for allowed paths."""
import json
import sys
import os
from pathlib import Path

SETTINGS_FILE = Path.home() / ".claude" / "settings.json"

def load_path_patterns():
    edit_patterns = []
    write_patterns = []

    if SETTINGS_FILE.exists():
        with open(SETTINGS_FILE, 'r') as f:
            settings = json.load(f)
            permissions = settings.get("permissions", {})

            for rule in permissions.get("allow", []):
                if rule.startswith("Edit(") and rule.endswith(")"):
                    edit_patterns.append(rule[5:-1])
                elif rule.startswith("Write(") and rule.endswith(")"):
                    write_patterns.append(rule[6:-1])

    return edit_patterns, write_patterns

def path_matches_pattern(file_path: str, pattern: str) -> bool:
    file_path = os.path.normpath(file_path)
    if "**" in pattern:
        base_path = pattern.replace("**", "").rstrip("/")
        return file_path.startswith(base_path)
    return False

def main():
    try:
        input_data = json.load(sys.stdin)
    except json.JSONDecodeError:
        sys.exit(0)

    tool_name = input_data.get("tool_name", "")
    tool_input = input_data.get("tool_input", {})
    edit_patterns, write_patterns = load_path_patterns()

    patterns = []
    if tool_name == "Edit":
        patterns = edit_patterns
    elif tool_name == "Write":
        patterns = write_patterns
    else:
        sys.exit(0)

    file_path = tool_input.get("file_path", "")
    for pattern in patterns:
        if path_matches_pattern(file_path, pattern):
            print(json.dumps({
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "allow",
                    "permissionDecisionReason": f"Path matches: {pattern}"
                }
            }))
            break

    sys.exit(0)

if __name__ == "__main__":
    main()

Hook Configuration

Add to settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit",
        "hooks": [{
          "type": "command",
          "command": "/path/to/auto-approve-file-ops.py",
          "timeout": 5000
        }]
      },
      {
        "matcher": "Write",
        "hooks": [{
          "type": "command",
          "command": "/path/to/auto-approve-file-ops.py",
          "timeout": 5000
        }]
      }
    ]
  }
}

Summary

The permission system appears to be completely broken for all tool types:

  • Bash: Reported in original issue
  • Edit: Confirmed broken
  • Write: Confirmed broken

The documented permissions.allow patterns are not being checked by the core permission system, forcing users to implement their own enforcement via hooks.

mdbudnick · 7 months ago

🦗 🦗 🦗

ondrejtucny · 7 months ago

The current state of permissions and how they are repeatedly requested is insanely bad and needs to be fixed ASAP. It has major impact on productivity. Further, when multiple agents are executing, the responsivity of permissions prompts shown is virtually close to none and the TUI becomes unusable.

Sendarg · 7 months ago

@lucasmccomb

Using fnmatch enables you to place wildcards () anywhere in your patterns (e.g., mkdir _output_*), allowing you to match commands with variable middle sections.
Additionally, the permissionDecisionReason returned in hookSpecificOutput perfectly supports these patterns, clearly identifying which wildcard rule authorized the action.

for example in settings.json:
`"permissions": {
"allow": [
// Matches any mkdir command have _output_
"Bash(mkdir -p _output_)",

// Matches any .log file within any "logs" directory at any depth
"Read(/logs/.log)",

// Matches any python script in the src directory of any project user
"Edit(/Users//project/src/.py)"
]
}`

update in auto-approve-*.py:
`
import fnmatch
def path_matches_pattern(file_path: str, pattern: str) -> bool:
file_path = os.path.normpath(file_path)

# Support wildcard matching (glob pattern)
if '*' in pattern and fnmatch.fnmatch(file_path, pattern):
return True

if "" in pattern:
base_path = pattern.replace("
", "").rstrip("/")
return file_path.startswith(base_path)
return False
`

lucasmccomb · 7 months ago

thank you @Sendarg! I will look into adding this.

instantsoup · 6 months ago

I just posted https://github.com/anthropics/claude-code/issues/24832 and am curious if this is the same issue or just similar. Is permissions.bash.blocked the same feature as permissions.deny? My config format is different but also not enforced.

alessandrosantospirito · 6 months ago

Had a similar issue on "Claude Version: 2.1.22 (Claude Code)" where the agent committed and pushed, even though he should not have.
Claude used for that

git add scripts/run_test_builder.sh && git commit -m "$(cat <<'EOF'
test_builder: clear pycache before run to pick up source changes
EOF
)" && git push

This should not been possible due to my settings (at least from a user point of view, I understand why it was possible)

```json {
{
"permissions": {
"deny": [
"Bash(git commit )", "Bash(git push )"
]
}
}

yurukusa · 5 months ago

You've identified the right solution. Here's a complete hook that replaces the broken permissions.allow/deny system:

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$COMMAND" ] && exit 0
CLEAN=$(echo "$COMMAND" | tr '\n' ' ')
if echo "$CLEAN" | grep -qiE '(rm\s+-rf\s+/|DROP\s+TABLE|curl.*\|\s*bash|format\s+c:)'; then
    echo "BLOCKED by deny rule" >&2
    exit 2
fi
PARTS=$(echo "$CLEAN" | sed 's/#.*$//' | sed 's/\s*&&\s*/\n/g; s/\s*||\s*/\n/g; s/\s*;\s*/\n/g; s/\s*|\s*/\n/g')
ALL_SAFE=true
while IFS= read -r part; do
    part=$(echo "$part" | sed 's/^\s*//;s/\s*$//')
    [ -z "$part" ] && continue
    BASE=$(echo "$part" | awk '{print $1}')
    case "$BASE" in
        cd|ls|cat|head|tail|grep|rg|find|stat|wc|du|tree|file|which|realpath) ;;
        echo|printf|true|false|test|export|set|env|date|sleep) ;;
        sort|uniq|cut|tr|awk|sed|jq|yq|tee|xargs) ;;
        git) ;; # All git ops allowed — adjust as needed
        mkdir|touch|pwd|pushd|popd) ;;
        npm|npx|node|yarn|python|python3|pip|pytest|make|cargo) ;;
        *) ALL_SAFE=false; break ;;
    esac
done <<< "$PARTS"
if [ "$ALL_SAFE" = true ]; then
    jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"Allowed by hook-based permission system"}}'
fi
exit 0
// ~/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{ "type": "command", "command": "bash ~/.claude/hooks/reliable-permissions.sh" }]
    }]
  }
}

The hook receives the raw command via JSON, splits compound commands itself, and checks each component. It doesn't depend on the pattern matcher that has the known bugs (wildcard matching, escape handling, heredoc parsing, compound command splitting).
Deny rules are also enforced reliably — the hook checks them first and exit 2 blocks the command regardless.

aczekajski · 1 month ago

thank you for the workaround, this is golden!!! I had to ask claude to rewrite it into node tho since I had no python installed but it worked 🤣

lucasmccomb · 1 month ago

I haven't had an issue with this anymore. Closing.

Showing cached comments. Read the full discussion on GitHub ↗