Skip to content

Security: PreToolUse hook bypasses Claude Code deny rules via permissionDecision: allow #260

@alxsbn

Description

@alxsbn

Problem

The RTK PreToolUse rewrite hook (rtk-rewrite.sh) outputs permissionDecision: "allow" alongside
updatedInput. This completely bypasses Claude Code's permission system, including deny rules configured in
.claude/settings.json.

Reproduction

A project has these deny rules in .claude/settings.json:

{
  "permissions": {
    "deny": [
      "Bash(git push --force)",
      "Bash(git push -f)",
      "Bash(git branch -D)",
      "Bash(gh pr merge)",
      "Bash(sudo:*)"
    ]
  }
}

When the hook is active:

  1. Claude generates git push --force
  2. Hook matches push * → rewrites to rtk git push --force
  3. Hook returns permissionDecision: "allow"deny rule is never evaluated
  4. Command executes without any user prompt

Affected commands

The hook matches broad git/gh patterns that include dangerous subcommands:

Command Hook pattern Deny rule bypassed
git push --force push * Bash(git push --force)
git push -f push * Bash(git push -f)
git push --force-with-lease push * Bash(git push --force-with-lease)
git branch -D main branch * Bash(git branch -D)
gh pr merge 123 gh pr * Bash(gh pr merge)
gh pr close 123 gh pr * Bash(gh pr close)
gh issue close 42 gh issue * Bash(gh issue close)
gh release delete v1 gh release * Bash(gh release delete)

Commands not in the hook's match list (e.g. git reset --hard, sudo) are unaffected — they exit 0 and normal
permissions apply.

Proposed fix

Before emitting permissionDecision: "allow", the hook should dynamically check deny patterns from Claude Code
settings files. If the command matches a deny rule, exit 0 (pass-through to normal permission flow) instead of
rewriting.

Implementation

# After extracting FIRST_CMD, before any rewrite logic:

_matches_deny() {
  local cmd="$1"
  shift
  for settings_file in "$@"; do
    [ -f "$settings_file" ] || continue
    while IFS= read -r raw; do
      [ -z "$raw" ] && continue
      local inner="${raw#Bash(}"
      inner="${inner%)}"
      if [[ "$inner" == *":*" ]]; then
        # Wildcard: Bash(sudo:*) → prefix match on "sudo"
        local prefix="${inner%:*}"
        [[ "$cmd" == "$prefix" || "$cmd" == "$prefix "* ]] && return 0
      else
        # Exact: Bash(git push --force) → prefix match
        [[ "$cmd" == "$inner" || "$cmd" == "$inner "* ]] && return 0
      fi
    done < <(jq -r '.permissions.deny[]? | select(startswith("Bash("))' "$settings_file" 2>/dev/null)
  done
  return 1
}

# Discover project root (git, then walk-up fallback)
PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
if [ -z "$PROJECT_ROOT" ]; then
  _dir="$PWD"
  while [ "$_dir" != "/" ]; do
    [ -f "$_dir/.claude/settings.json" ] && { PROJECT_ROOT="$_dir"; break; }
    _dir=$(dirname "$_dir")
  done
fi

DENY_SOURCES=()
[ -n "$PROJECT_ROOT" ] && DENY_SOURCES+=("$PROJECT_ROOT/.claude/settings.json"
"$PROJECT_ROOT/.claude/settings.local.json")
DENY_SOURCES+=("$HOME/.claude/settings.json" "$HOME/.claude/settings.local.json")

if _matches_deny "$FIRST_CMD" "${DENY_SOURCES[@]}"; then
  exit 0  # Let normal permission flow handle this command
fi

Design choices

  • Dynamic: reads deny rules at runtime from all 4 settings files (project + global, shared + local)
  • Fallback: walks up $PWD if not in a git repo (covers non-git projects and worktree edge cases)
  • Zero config: users don't need to maintain a separate deny list — RTK respects their existing Claude Code
    settings
  • Performance: jq on small JSON files adds ~5ms, well within hook timeout

Relationship to #243

#243 proposes exclude_commands config for compatibility reasons (some commands break with RTK). This issue
is complementary — it addresses the security angle: commands that work fine with RTK but should never be
auto-allowed because the user explicitly denied them.

Both could coexist: deny-rule checking as a built-in safety net, exclude_commands as user preference.

Alternative approach

Don't emit permissionDecision: "allow" at all — just return updatedInput and let Claude Code's permission
system evaluate the rewritten command. This would require users to add Bash(rtk:*) to their allow rules and
mirror all deny rules with rtk prefix. Simpler hook, but much worse UX.

Environment

  • RTK: 0.22.2
  • Claude Code: latest
  • OS: Linux

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions