Skip to content

fix(hook): respect Claude Code deny/ask permission rules on rewrite#576

Open
FlorianBruniaux wants to merge 4 commits intodevelopfrom
fix/hook-bypass-permission
Open

fix(hook): respect Claude Code deny/ask permission rules on rewrite#576
FlorianBruniaux wants to merge 4 commits intodevelopfrom
fix/hook-bypass-permission

Conversation

@FlorianBruniaux
Copy link
Collaborator

Problem

The PreToolUse hook (rtk-rewrite.sh) was emitting permissionDecision: "allow" on every rewritten command, silently bypassing deny and ask rules defined in .claude/settings.json.

Concrete example: user has Bash(git push --force) in deny rules → hook rewrites to rtk git push --force with auto-allow → command executes without confirmation. P1-critical bug reported by the community and confirmed by @pszymkowiak.

Closes #260

Solution

Permission logic lives in the Rust binary (rtk rewrite), not the shell. The hook interprets exit codes.

Exit code protocol:

Exit Stdout Meaning
0 rewritten No deny/ask rule matched — hook auto-allows
1 (none) No RTK equivalent — passthrough unchanged
2 (none) Deny rule matched — CC native deny handles it
3 rewritten Ask rule matched — rewrite without permissionDecision (CC prompts user)

Changes

  • src/permissions.rs (new) — loads Bash(...) deny/ask rules from all 4 Claude Code settings files ($PROJECT_ROOT/.claude/settings.json, .local.json, ~/.claude/settings.json, .local.json). Checks commands including compound (&&, ||, |, ;) and returns Allow / Deny / Ask verdict. Deny takes priority over Ask. 16 unit tests.
  • src/rewrite_cmd.rs — after finding a rewrite, calls check_command() on the original command. Exits 0/2/3 accordingly.
  • hooks/rtk-rewrite.sh + .claude/hooks/rtk-rewrite.sh — handle exit codes 2 (passthrough silently) and 3 (rewrite without permissionDecision). Hook version bumped 2→3.
  • src/hook_check.rsCURRENT_HOOK_VERSION 2→3 so existing users get the upgrade prompt.
  • ARCHITECTURE.md — updated module count.

Test plan

  • 16 unit tests in src/permissions.rs (exact match, prefix match, wildcard, deny precedence, compound commands, word boundary)
  • Full test suite: 915 passed
  • Manual: echo '{"permissions":{"deny":["Bash(git push --force)"]}}' > .claude/settings.jsonrtk rewrite "git push --force" exits 2
  • Manual: ask rule → exits 3 with rewritten command in stdout
  • Manual: rtk rewrite "git status" → exits 0 as before (no regression)
  • set -euo pipefail bug fixed in .claude/hooks/rtk-rewrite.sh (use || EXIT_CODE=$? pattern)

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings March 13, 2026 14:20
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Claude Code permission-awareness to the rtk rewrite flow so hooks can avoid auto-allowing rewritten commands when deny/ask rules match, preserving user intent while still enabling rewrites.

Changes:

  • Introduces src/permissions.rs to load Claude Code settings and evaluate deny/ask Bash rules against commands.
  • Updates rtk rewrite to return a richer exit-code protocol (0/1/2/3) to drive hook behavior (auto-allow vs prompt vs passthrough).
  • Updates hook scripts and hook versioning to handle the new exit codes, and documents the new module in ARCHITECTURE.md.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/rewrite_cmd.rs Adds permission checking and new exit-code protocol for rtk rewrite.
src/permissions.rs Implements settings discovery + deny/ask rule matching (with unit tests).
src/main.rs Registers the new permissions module.
src/hook_check.rs Bumps expected hook version to 3.
hooks/rtk-rewrite.sh Updates hook logic to respect deny/ask via rtk rewrite exit codes.
.claude/hooks/rtk-rewrite.sh Same as above, with audit logging retained.
ARCHITECTURE.md Documents the new module and updates module counts.
Comments suppressed due to low confidence (1)

ARCHITECTURE.md:301

  • The module counts in this section are internally inconsistent after the update: it now claims “Total: 65 modules (38 command + 27 infrastructure)”, but the breakdown immediately below still lists different numbers (e.g. “Command Modules: 34”). Please reconcile these counts (and the “Complete Module Map (30 Modules)” heading earlier in this section if it’s still accurate).
**Total: 65 modules** (38 command modules + 27 infrastructure modules)

### Module Count Breakdown

- **Command Modules**: 34 (directly exposed to users)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +32 to +35
PermissionVerdict::Ask => {
print!("{}", rewritten);
std::process::exit(3);
}
mpecan added a commit to mpecan/tokf that referenced this pull request Mar 13, 2026
The PreToolUse hook was emitting permissionDecision: "allow" on every
rewritten command, silently bypassing deny and ask rules configured in
Claude Code settings.json. For example, a user with Bash(git push --force)
in their deny list would have the command rewritten and auto-allowed.

Add a permissions module that loads Bash(...) deny/ask rules from all 4
Claude Code settings files and checks the original command before deciding
the hook response:

- Deny match → pass through (return false), native deny handles it
- Ask match → rewrite but omit permissionDecision, tool prompts user
- No match → rewrite with permissionDecision: "allow" (existing behavior)

Same fix applied to Claude Code, Gemini CLI, and Cursor hook handlers.

Ref: rtk-ai/rtk#576

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
mpecan added a commit to mpecan/tokf that referenced this pull request Mar 13, 2026
The PreToolUse hook was emitting permissionDecision: "allow" on every
rewritten command, silently bypassing deny and ask rules configured in
Claude Code settings.json. For example, a user with Bash(git push --force)
in their deny list would have the command rewritten and auto-allowed.

Add a permissions module that loads Bash(...) deny/ask rules from all 4
Claude Code settings files and checks the original command before deciding
the hook response:

- Deny match → pass through (return false), native deny handles it
- Ask match → rewrite but omit permissionDecision, tool prompts user
- No match → rewrite with permissionDecision: "allow" (existing behavior)

Same fix applied to Claude Code, Gemini CLI, and Cursor hook handlers.

Ref: rtk-ai/rtk#576

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@aeppling
Copy link
Contributor

follow-up wildcards issue

The PR's pattern matching only handles * at the end of a pattern. Claude Code's permission system supports *
anywhere.

Works:

  • Bash(git push --force) — exact match
  • Bash(git push *) — trailing wildcard
  • Bash(sudo:*) — legacy colon syntax

Doesn't work:

  • Bash(* --force) — leading wildcard. User wants to deny any command ending with --force. The PR sees * --force,
    doesn't find a trailing *, falls through to exact match, matches nothing. Hook auto-allows.
  • Bash(git * main) — middle wildcard. User wants to deny git push main, git merge main, etc. Same problem — no
    trailing *, treated as literal string, matches nothing.
  • Bash(* --help *) — multiple wildcards. Same.

mpecan added a commit to mpecan/tokf that referenced this pull request Mar 13, 2026
…289)

## Summary

- The `PreToolUse` hook was emitting `permissionDecision: "allow"` on
every rewritten command, silently bypassing deny and ask rules
configured in Claude Code `settings.json`
- Adds a `permissions` module that loads `Bash(...)` deny/ask rules from
all 4 Claude Code settings files and checks the **original** command
before deciding the hook response
- **Deny** → pass through silently, letting Claude Code's native deny
handle it
- **Ask** → rewrite but omit `permissionDecision`, so the tool prompts
the user
- **Allow** → rewrite with `permissionDecision: "allow"` (existing
behavior)
- Same fix applied to Claude Code, Gemini CLI, and Cursor hook handlers

Ref: rtk-ai/rtk#576

## Test plan

- [x] 17 unit tests in `permissions.rs` (exact match, prefix match,
wildcard, deny precedence, compound commands, word boundary, settings
file integration)
- [x] Full test suite: 1324 passed, 0 failed
- [x] Clippy clean, fmt clean
- [x] Existing hook handle and install tests still pass

Credit: rtk-ai/rtk#576 - thank you
@FlorianBruniaux

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@pszymkowiak
Copy link
Collaborator

Ran a second deeper review focused on edge cases. Found 2 additional gaps beyond @aeppling's wildcard finding.

Bug 1 (Critical): deny rules skipped for non-RTK commands

check_command() only runs inside the Some(rewritten) branch (rewrite_cmd.rs:38). When rewrite_command returns None (no RTK equivalent), exit 1 is returned immediately — deny rules are never consulted.

Commands that return None include everything in IGNORED_PREFIXES: rm, mv, cp, chmod, kill, bash, python3 -c, etc.

Concrete failure: user adds Bash(rm -rf *) to deny list. Claude runs rm -rf /. Hook calls rtk rewrite "rm -rf /" → exit 1 (no rewrite) → hook passes through. Deny rule never checked.

Fix: run check_command before the rewrite match, not inside it.

Bug 2 (Critical): *:* pattern matches nothing

Bash(*:*) — a user expects this to block everything. strip_suffix('*')*:, trim_end_matches(':')*, then checks cmd == "*" → false. Pattern silently matches nothing.

Fix: add || prefix == "*" to the empty-prefix guard at permissions.rs:180.

@aeppling's wildcard finding confirmed

Leading (* --force), middle (git * main), and multiple (* --force *) wildcards all silently return false. Only trailing wildcards work.

Summary

Bug Impact Status
Deny skipped for non-RTK commands Security bypass for rm, mv, kill, etc. New finding
*:* matches nothing Catch-all deny rule is a no-op New finding
Leading/middle wildcards @aeppling's finding, confirmed Known

Core architecture is solid — these are fixable without redesign. Happy to re-review after fixes.

@FlorianBruniaux
Copy link
Collaborator Author

@pszymkowiak @aeppling All four findings addressed. Two commits on this branch:

53b8366 — rewrite_cmd.rs (Bug 1 + Bug 4)

  • check_command() now runs before registry::rewrite_command(), so non-RTK commands (rm, kill, python3 -c) hit deny rules instead of bypassing them.
  • Added explicit stdout().flush() before each process::exit().

6ddc805 — permissions.rs (Bug 2 + Bug 3)

  • *:* catch-all: after strip_suffix('*') + trim, we now detect an empty-or-* prefix and return true.
  • Leading/middle/multi wildcards: new glob_matches() function with segment anchoring (first = prefix, last = suffix, middle = str::find in order). * --force, git * main, git * --force * all work correctly.

8 new tests cover all the fixed cases, including integration variants with check_command_with_rules. 945 passing, 0 failures.

@FlorianBruniaux FlorianBruniaux force-pushed the fix/hook-bypass-permission branch 2 times, most recently from 6bf2100 to 0841ec3 Compare March 16, 2026 10:58
@pszymkowiak
Copy link
Collaborator

@FlorianBruniaux This PR has a merge conflict with develop (likely src/main.rs). Could you rebase on develop and enable "Allow edits by maintainers" so we can merge faster?

git fetch upstream develop
git rebase upstream/develop
# resolve conflicts
git push --force-with-lease

This is P1 security — we'd like to get it in the next release. Thanks!

FlorianBruniaux and others added 4 commits March 17, 2026 18:34
The PreToolUse hook was emitting `permissionDecision: "allow"` on every
rewritten command, bypassing deny and ask rules in .claude/settings.json.

- Add `src/permissions.rs`: loads Bash deny/ask rules from all 4 Claude
  Code settings files (project + global, settings.json + settings.local.json),
  checks commands (including compound && / || / | / ;) and returns
  Allow / Deny / Ask verdict. 16 unit tests.
- Modify `src/rewrite_cmd.rs`: after finding a rewrite, check the original
  command against permissions. Exit 0 = allow (auto-approve rewrite),
  exit 2 = deny (passthrough, let CC native deny handle it),
  exit 3 = ask (print rewrite but no permissionDecision, CC prompts user).
- Update both hook files to handle exit codes 2 and 3. Version bumped 2→3.
- Bump `CURRENT_HOOK_VERSION` 2→3 in `hook_check.rs` so users with the old
  hook get the upgrade prompt.
- Fix set -euo pipefail bug in .claude/hooks/rtk-rewrite.sh: capture exit
  code with `|| EXIT_CODE=$?` instead of bare assignment.

Fixes #260

Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
Bug 1 (Critical): check_command() was called inside Some(rewritten),
so non-RTK commands (rm, kill, python3 -c) bypassed deny rules entirely.
Move verdict check before registry::rewrite_command() so all commands
are evaluated regardless of whether RTK has an equivalent.

Bug 4 (Medium): print!() before process::exit() could leave stdout
unflushed. Add explicit std::io::stdout().flush() after each print!().

Add Eq derive to PermissionVerdict (required for == comparison).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
Bug 2 (Critical): *:* catch-all matched nothing. strip_suffix('*') left
"*:" which after trim became "*" (non-empty), so the branch returned
false instead of true. Fix: detect empty-or-star prefix after stripping.

Bug 3 (Medium): leading wildcards ("* --force"), middle wildcards
("git * main"), and multi-wildcard patterns ("git * --force *") fell
through to exact match, silently failing. Add glob_matches() with
character-level segment anchoring: first segment must be prefix, last
must be suffix, middle segments found via str::find in order.

Colon normalization in glob_matches(): "sudo:*" -> "sudo *" so both
fast path and glob path interpret colon syntax consistently.

New tests: test_star_colon_star_matches_everything,
test_leading_wildcard, test_leading_wildcard_no_partial,
test_middle_wildcard, test_middle_wildcard_no_match,
test_multiple_wildcards, test_deny_with_leading_wildcard,
test_deny_star_colon_star.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
validate-docs.sh counts mod declarations in main.rs (66).
Update ARCHITECTURE.md total to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
@FlorianBruniaux FlorianBruniaux force-pushed the fix/hook-bypass-permission branch from 0841ec3 to 8a70932 Compare March 17, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants