fix(hook): respect Claude Code deny/ask permission rules on rewrite#576
fix(hook): respect Claude Code deny/ask permission rules on rewrite#576FlorianBruniaux wants to merge 18 commits intodevelopfrom
Conversation
There was a problem hiding this comment.
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.rsto load Claude Code settings and evaluatedeny/askBash rules against commands. - Updates
rtk rewriteto 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.
src/rewrite_cmd.rs
Outdated
| PermissionVerdict::Ask => { | ||
| print!("{}", rewritten); | ||
| std::process::exit(3); | ||
| } |
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>
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>
follow-up wildcards issueThe PR's pattern matching only handles * at the end of a pattern. Claude Code's permission system supports * Works:
Doesn't work:
|
…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>
|
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
Commands that return Concrete failure: user adds Fix: run Bug 2 (Critical):
|
| 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.
|
@pszymkowiak @aeppling All four findings addressed. Two commits on this branch:
8 new tests cover all the fixed cases, including integration variants with |
6bf2100 to
0841ec3
Compare
* fix: P1 exit codes, grep regex perf, SQLite concurrency Exit code propagation (same pattern as existing modules): - wget_cmd: run() and run_stdout() now exit on failure - container: docker_logs, kubectl_pods/services/logs now check status before parsing JSON (was showing "No pods found" on error) - pnpm_cmd: replace bail!() with eprint + process::exit in run_list and run_install Performance: - grep_cmd: compile context regex once before loop instead of per-line in clean_line() (was N compilations per grep call) Data integrity: - tracking: add PRAGMA journal_mode=WAL and busy_timeout=5000 to prevent SQLite corruption with concurrent Claude Code instances Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: address review findings on P1 fixes - tracking: WAL pragma non-fatal (NFS/read-only compat) - wget: forward raw stderr on failure, track raw==raw (no fake savings) - container: remove stderr shadow in docker_logs, add empty-stderr guard on all 4 new exit code paths for consistency with prisma pattern Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
* fix: raise output caps for grep, git status, and parser fallback (#617, #618, #620) - grep: per-file match cap 10 → 25, global max 50 → 200 - git status: file list caps 5/5/3 → 15/15/10 - parser fallback: truncate 500 → 2000 chars across all modules These P0 bugs caused LLM retry loops when RTK returned less signal than the raw command, making RTK worse than not using it. Fixes #617, #618, #620 Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: update README example and add truncation tests for modified/untracked - parser/README.md: update example from 500 → 2000 to match code - git.rs: add test_format_status_modified_truncation (cap 15) - git.rs: add test_format_status_untracked_truncation (cap 10) Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * refactor: extract output caps into [limits] config section Move hardcoded caps into config.toml so users can tune them: [limits] grep_max_results = 200 # global grep match limit grep_max_per_file = 25 # per-file match limit status_max_files = 15 # staged/modified file list cap status_max_untracked = 10 # untracked file list cap passthrough_max_chars = 2000 # parser fallback truncation All 8 modules now read from config::limits() instead of hardcoded values. Defaults unchanged from previous commit. Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
…es (#662) * feat(.claude): add /rtk-triage skill — orchestrated PR+issue cross-analysis New skill that runs issue-triage + pr-triage in parallel then produces a cross-analysis layer that neither skill can do individually: - Double coverage detection: identifies when 2+ PRs target the same issue (via body scan + file overlap), recommends which to keep/close - Security gap detection: for security review issues, maps each finding to a PR (or flags it as uncovered) - P0/P1 bugs without PR: groups by pattern to suggest sprint batching - Our dirty PRs: identifies probable cause (conflict with sibling PR, needs rebase, missing linked issue) Output is saved automatically to claudedocs/RTK-YYYY-MM-DD.md. Usage: /rtk-triage (French, auto-save) /rtk-triage en (English output) Signed-off-by: Florian Bruniaux <florian@bel-etage.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com> * docs(architecture): update module count to 66 Sync ARCHITECTURE.md with current main.rs state. Previous count (60) was stale since several modules were added (dotnet_cmd, dotnet_format_report, dotnet_trx, npm_cmd, gt_cmd, etc.). Signed-off-by: Florian Bruniaux <florian@bel-etage.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com> --------- Signed-off-by: Florian Bruniaux <florian@bel-etage.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
- git stash: pass unknown subcommands (save, branch, clear) through instead of silently falling back to git stash push - git branch: add --show-current, --set-upstream-to, --format, --sort to flag detection so they don't get overridden by -a injection - pip: replace bail!() with passthrough for unknown subcommands (freeze, download, wheel, etc.) Fixes #600 Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
|
@FlorianBruniaux This PR has a merge conflict with develop (likely git fetch upstream develop
git rebase upstream/develop
# resolve conflicts
git push --force-with-leaseThis is P1 security — we'd like to get it in the next release. Thanks! |
cargo fmt diffs in config.rs, git.rs, playwright_cmd.rs were failing the fmt CI check, which cascaded to block clippy/test/security on PRs #632, #635, #638. Also fixes all clippy warnings: dead code annotations, iterator simplifications, assert patterns, and unnecessary allocations. Signed-off-by: Patrick Szymkowiak <patrick@rtk-ai.app> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
* fix: discover classifies absolute paths like /usr/bin/grep (#485) Normalize absolute binary paths before classification: /usr/bin/grep → grep, /bin/ls → ls, /usr/local/bin/git → git Adds strip_absolute_path() helper + 5 tests. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: discover and rewrite support git global options -C, --no-pager, etc. (#163) Strip git global options (-C <path>, -c <key=val>, --git-dir, --work-tree, --no-pager, --no-optional-locks, --bare, --literal-pathspecs) before classification so git -C /tmp status is recognized as rtk git. Rewrite preserves global options: git -C /tmp status → rtk git -C /tmp status Adds GIT_GLOBAL_OPT lazy_static regex + strip_git_global_opts() helper + 6 tests. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
When running `rtk cargo clippy -p my-crate -- -D warnings`, Clap with `trailing_var_arg = true` preserves the `--` in parsed args when flags precede it. `restore_double_dash()` then added a second `--`, producing `cargo clippy -p my-crate -- -- -D warnings`. This caused rustc to interpret `-D` as a filename instead of a lint flag. Fix: skip restoration when args already contain `--` (Clap preserved it). Fixes #496 Signed-off-by: Ousama Ben Younes <benyounes.ousama@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- PR template reminds contributors to target develop - CI workflow labels PRs targeting master with 'wrong-base' and posts a comment - Excludes develop→master PRs (maintainer releases) Signed-off-by: Patrick <patrick@rtk-ai.com> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
Add Language::Data variant for data formats (JSON, YAML, TOML, XML, CSV, etc.) with empty comment patterns to prevent comment stripping. AggressiveFilter falls back to MinimalFilter for data files. Fixes #464 Signed-off-by: Ousama Ben Younes <benyounes.ousama@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…gh (#427) (#564) When compact_diff truncates output, append a hint line so Claude knows how to get the full diff: [full diff: rtk git diff --no-compact] Also fix --no-compact flag being passed to git (causing usage error) and remove decorative emoji from compact_diff output. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
#632) 4 P1 bugs where git exit codes were swallowed: - git diff: failure silently printed empty stat output - git status (with args): failure was filtered instead of propagated - git commit: failure printed "FAILED" but returned Ok(()) breaking pre-commit hooks - git branch (list mode): failure was silently ignored All now follow the established pattern: eprint stderr, track raw==raw, process::exit(code). Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
…635) * feat: add 5 new TOML built-in filters (ollama, nx, gradle, spring-boot, jira) New filters for commands not covered by Rust modules: - ollama: strip ANSI spinners, keep final text response (#624) - nx: strip Nx monorepo noise, keep build results (#444) - gradle/gradlew: strip UP-TO-DATE tasks, keep build summary (#147) - spring-boot: strip banner and verbose logs, keep startup/errors (#147) - jira: strip blanks, truncate wide columns (#524) All 5 filters pass inline tests via rtk verify (123/123). Updated builtin filter count: 47 -> 52. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * feat: add 5 more TOML filters (turbo, mise, just, task, yadm) New filters for task runners and git wrapper: - turbo: strip cache/Tasks/Duration noise, keep task output (#531) - mise: strip install/download progress, keep task results (#607) - just: strip blanks and recipe headers, keep output (#607) - task: strip task headers and up-to-date lines, keep results (#607) - yadm: strip hint lines, compact git-like output (#567) All verified with fake binaries through catch-all TOML engine. 137/137 TOML tests pass, 934 Rust tests pass. Updated builtin filter count: 52 -> 57. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
Git status output used emojis (📌, 📝, ❓, ✅,⚠️ ) that confuse non-Claude LLMs (GPT, etc.) causing retry loops. Replace with plain text labels (branch:, modified:, staged:, untracked:, conflicts:). Also add "clean — nothing to commit" when working tree is clean, so LLMs understand the repo state without ambiguity. Before: 📌 master After: branch: master clean — nothing to commit Fixes #603 Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
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>
0841ec3 to
8a70932
Compare
Problem
The
PreToolUsehook (rtk-rewrite.sh) was emittingpermissionDecision: "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 tortk git push --forcewith 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:
permissionDecision(CC prompts user)Changes
src/permissions.rs(new) — loadsBash(...)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 returnsAllow/Deny/Askverdict. Deny takes priority over Ask. 16 unit tests.src/rewrite_cmd.rs— after finding a rewrite, callscheck_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 withoutpermissionDecision). Hook version bumped 2→3.src/hook_check.rs—CURRENT_HOOK_VERSION2→3 so existing users get the upgrade prompt.ARCHITECTURE.md— updated module count.Test plan
src/permissions.rs(exact match, prefix match, wildcard, deny precedence, compound commands, word boundary)echo '{"permissions":{"deny":["Bash(git push --force)"]}}' > .claude/settings.json→rtk rewrite "git push --force"exits 2rtk rewrite "git status"→ exits 0 as before (no regression)set -euo pipefailbug fixed in.claude/hooks/rtk-rewrite.sh(use|| EXIT_CODE=$?pattern)🤖 Generated with Claude Code