fix(hook): replace rtk-rewrite.sh with native rtk hook claude (no jq dependency)#862
fix(hook): replace rtk-rewrite.sh with native rtk hook claude (no jq dependency)#862FlorianBruniaux wants to merge 5 commits intodevelopfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR migrates the Claude Code PreToolUse rewrite hook from an installed shell script (with jq dependency) to a native Rust subcommand (rtk hook claude), and updates global init to register/migrate the hook in ~/.claude/settings.json.
Changes:
- Add
rtk hook claudesubcommand and core hook logic (process_claude_hook) with unit tests. - Update
rtk init -gto install the hook asrtk hook claude(and migrate legacyrtk-rewrite.shsettings entries). - Adjust legacy hook script handling/tests and related init output.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/hook_cmd.rs | Adds native Claude hook processing + tests. |
| src/main.rs | Registers hook claude subcommand and dispatch. |
| src/init.rs | Stops installing shell hook file; patches/migrates Claude settings.json to use rtk hook claude; updates tests/output. |
| src/integrity.rs | Makes legacy hook hash baseline writer test-only. |
| src/rake_cmd.rs | Minor Rust idiom tweak (is_some_and). |
| // Only handle Bash tool | ||
| let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); | ||
| if tool_name != "Bash" { |
There was a problem hiding this comment.
process_claude_hook only rewrites when tool_name is exactly "Bash", but this codebase already treats VS Code / Claude-style hooks as potentially using "bash" (lowercase) or "runTerminalCommand" (see detect_format). With the new rtk hook claude entrypoint, this strict check can cause the hook to silently do nothing for valid inputs. Consider accepting the same tool_name variants as detect_format (or reusing detect_format/handle_vscode) so Claude Code inputs don’t regress.
| // Only handle Bash tool | |
| let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); | |
| if tool_name != "Bash" { | |
| // Only handle bash-like tools (VS Code / Claude variants) | |
| let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); | |
| if tool_name != "Bash" && tool_name != "bash" && tool_name != "runTerminalCommand" { |
| // Migrate old rtk-rewrite.sh hook to native command | ||
| if migrate_legacy_hook(&mut root, hook_command) { | ||
| if verbose > 0 { | ||
| eprintln!("Migrated rtk-rewrite.sh → {hook_command}"); | ||
| } | ||
| let serialized = | ||
| serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; | ||
| atomic_write(&settings_path, &serialized)?; | ||
| println!("\n settings.json: migrated rtk-rewrite.sh → {hook_command}"); | ||
| if include_opencode { | ||
| println!(" Restart Claude Code and OpenCode. Test with: git status"); | ||
| } else { | ||
| println!(" Restart Claude Code. Test with: git status"); | ||
| } | ||
| return Ok(PatchResult::Patched); | ||
| } |
There was a problem hiding this comment.
patch_settings_json performs legacy-hook migration and writes settings.json before honoring PatchMode. This means --no-patch (Skip) and Ask can still modify an existing settings file without consent, which is inconsistent with the documented patch modes. Consider gating migration behind the same mode/consent flow (or only auto-migrating when mode is Auto).
| eprintln!("Migrated rtk-rewrite.sh → {hook_command}"); | ||
| } | ||
| let serialized = | ||
| serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; |
There was a problem hiding this comment.
During the migration path, settings.json is rewritten via atomic_write without creating the usual .json.bak backup. Since this is modifying an existing user config file, it should follow the same backup behavior as the normal patch flow (copy to .json.bak before the write).
| serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; | |
| serde_json::to_string_pretty(&root).context("Failed to serialize settings.json")?; | |
| // Backup original before migration, consistent with normal patch flow | |
| if settings_path.exists() { | |
| let backup_path = settings_path.with_extension("json.bak"); | |
| fs::copy(&settings_path, &backup_path).with_context(|| { | |
| format!("Failed to backup to {}", backup_path.display()) | |
| })?; | |
| if verbose > 0 { | |
| eprintln!("Backup: {}", backup_path.display()); | |
| } | |
| } |
| // 3. Print success message | ||
| println!("\nRTK installed (global).\n"); | ||
| println!(" Hook: {CLAUDE_HOOK_CMD} (built-in, no dependencies)"); |
There was a problem hiding this comment.
Now that rtk init -g installs the Claude hook as a command (rtk hook claude) rather than writing ~/.claude/hooks/rtk-rewrite.sh, the rest of the codebase should stop treating the presence/version of rtk-rewrite.sh as the indicator of a working Claude hook. As-is, hook_check/integrity will likely report the hook as missing after a successful install, causing confusing warnings on every run. Consider updating the hook health checks to look for the settings.json entry (and treat the shell script as legacy-only).
| @@ -63,6 +63,7 @@ fn hash_path(hook_path: &Path) -> PathBuf { | |||
| /// against casual modification. Not a security boundary — an | |||
| /// attacker with write access can chmod it — but forces a | |||
| /// deliberate action rather than accidental overwrite. | |||
| #[cfg(test)] | |||
There was a problem hiding this comment.
Switching store_hash to #[cfg(test)] makes it impossible for production code to establish an integrity baseline for the legacy rtk-rewrite.sh hook, yet rtk verify/runtime_check still describe and depend on that baseline. If the intent is to deprecate file-based hook integrity entirely, the user-facing verify/runtime logic should be updated accordingly; otherwise store_hash likely needs to remain available (and called) when installing/updating the legacy hook.
| #[cfg(test)] |
| const REWRITE_HOOK: &str = include_str!("../hooks/rtk-rewrite.sh"); | ||
|
|
||
| // Native Rust hook command: no jq dependency, no shell script file to write | ||
| const CLAUDE_HOOK_CMD: &str = "rtk hook claude"; | ||
|
|
There was a problem hiding this comment.
With the hook command now being rtk hook claude, uninstall/removal logic needs to treat that command as an RTK hook entry too. Currently the settings.json removal path only matches commands containing rtk-rewrite.sh, so rtk init -g --uninstall will leave the new hook entry behind. Consider expanding removal matching to include the native command as well.
| /// Identifiers that mark a VS Code / Cursor hook entry as managed by RTK. | |
| /// | |
| /// This must include both the legacy shell script and the native hook command so that | |
| /// uninstall/removal logic can correctly detect and remove all RTK hook entries | |
| /// from settings.json (e.g. in `rtk init -g --uninstall`). | |
| const RTK_HOOK_IDENTIFIERS: [&str; 2] = ["rtk-rewrite.sh", CLAUDE_HOOK_CMD]; |
pszymkowiak
left a comment
There was a problem hiding this comment.
Excellent work @FlorianBruniaux — this is significantly better than #785. The migration logic (migrate_legacy_hook), the 10 tests, the description field preservation, and the correct absence of permissionDecision in the output all show careful attention to the Claude Code protocol.
The jq removal solves a real pain point (#430) and the native Rust path is the right direction.
P0 — Security: permissions deny/ask not called
process_claude_hook() calls get_rewritten() → rewrite_command() but never check_command() from src/permissions.rs. The current bash script calls rtk rewrite which internally runs check_command() and exits with:
- Exit 2 → deny (pass through unchanged, Claude Code native deny handles it)
- Exit 3 → ask (rewrite but omit
permissionDecision, Claude Code prompts user) - Exit 0 → allow (rewrite + auto-allow)
Without this, any command matching a user's permissions.deny or permissions.ask rules in settings.json is silently auto-allowed. This is a security regression from PR #576.
Fix sketch:
pub(crate) fn process_claude_hook(input: &str) -> Option<String> {
// ... parse JSON, extract cmd ...
// Check permissions BEFORE rewriting
match crate::permissions::check_command(cmd) {
PermissionVerdict::Deny => return None, // pass through, Claude Code handles deny
PermissionVerdict::Ask => {
let rewritten = get_rewritten(cmd)?;
// Rewrite but omit permissionDecision — Claude Code will prompt
return Some(json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"updatedInput": build_updated_input(&v, &rewritten)
}
}).to_string());
}
PermissionVerdict::Allow => {} // continue to normal rewrite
}
let rewritten = get_rewritten(cmd)?;
// ... current code with permissionDecision omitted (correct) ...
}P1 — Audit logging dropped
RTK_HOOK_AUDIT=1 writes to ~/.local/share/rtk/hook-audit.log in the bash script. The native command doesn't replicate this. Users relying on rtk hook-audit for rewrite metrics will get an empty log. Gate on std::env::var("RTK_HOOK_AUDIT") and write the same pipe-separated format.
P1 — Integrity check orphaned
store_hash() is now #[cfg(test)] only, but runtime_check() in integrity.rs still looks for ~/.claude/hooks/rtk-rewrite.sh. After migration, the file is gone → runtime_check() returns NotInstalled forever. Either disable runtime_check when using native hook mode, or adapt it to verify the "rtk hook claude" entry in settings.json instead.
P2 — Uninstall path
Does rtk uninstall also clean up the "rtk hook claude" entry from settings.json? The current uninstall removes rtk-rewrite.sh file — verify it also handles the new command string.
P2 — Scope: other agents
rtk hook gemini and rtk hook copilot already exist as native Rust commands. Do they also lack the permission check? Worth verifying for consistency — if Claude gets deny/ask, Gemini and Copilot should too.
Summary: fix the permissions check (P0), add audit logging (P1), and this is ready. The core implementation is solid — much cleaner than #785. Also closes the discussion on #785 since this PR supersedes it.
|
Additional P1 — Following up on the integrity point: the current
We lose:
The fix: adapt Something like: pub fn verify_hook() -> Result<IntegrityStatus> {
let settings = read_settings_json()?;
if hook_entry_present(&settings, "rtk hook claude") {
Ok(IntegrityStatus::Verified)
} else if hook_entry_present(&settings, "rtk-rewrite.sh") {
Ok(IntegrityStatus::NoBaseline) // legacy, needs migration
} else {
Ok(IntegrityStatus::NotInstalled)
}
}This keeps |
|
Hey We are cleaning up the codebase and improving the project structure for better onboarding. As part of this effort, PR #826 reorganizes No logic changes — only file moves and import path updates. What you need to doRebase your branch on git fetch origin && git rebase origin/developGit detects renames automatically. If you get import conflicts, update the paths: use crate::git; // now: use crate::cmds::git::git;
use crate::tracking; // now: use crate::core::tracking;
use crate::config; // now: use crate::core::config;
use crate::init; // now: use crate::hooks::init;
use crate::gain; // now: use crate::analytics::gain;Need help rebasing? Tag @aeppling |
Replace rtk-rewrite.sh (requires jq) with a native Rust subcommand `rtk hook claude` that reads Claude Code PreToolUse JSON from stdin and rewrites Bash tool commands to rtk equivalents. - Add `run_claude()` in hook_cmd.rs: parses JSON via serde_json, preserves all tool_input fields (description etc.), rewrites command - Add `HookCommands::Claude` variant and match arm in main.rs - Update `rtk init -g` to register `rtk hook claude` in settings.json instead of writing/installing rtk-rewrite.sh - Add migration: if legacy rtk-rewrite.sh entry is found in settings.json, automatically replace it with `rtk hook claude` - Update `hook_already_present` to detect both legacy and native commands - Remove `prepare_hook_paths` and `ensure_hook_installed` (dead code) - Mark `store_hash` and `REWRITE_HOOK` as #[cfg(test)] (test-only now) - Fix pre-existing clippy warning in rake_cmd.rs (map_or → is_some_and) - Add 5 new tests: Claude hook output format, migration, idempotency Fixes #430 (silent jq failure). Implements #154 (migrate to Rust). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
Extract core Claude Code hook logic into process_claude_hook(input: &str) -> Option<String> so tests call the real function, not hand-rolled JSON. - Replace indirect tests (manual JSON parsing, helper calls) with direct calls to process_claude_hook() on full hook input JSON - Add tests: cargo test rewrite, empty input, invalid JSON, heredoc, output schema validation (no permissionDecision in output) - Update test_default_mode_creates_hook_and_rtk_md → split into test_claude_hook_cmd_constant (verifies CLAUDE_HOOK_CMD = "rtk hook claude") and test_legacy_hook_script_has_proper_guards (legacy shell script docs) - 1139 tests total, 0 failures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
- CHANGELOG: add Unreleased entries for rtk hook claude feature and jq silent failure bug fix (#430) - README: update Claude Code row in supported tools table to show rtk hook claude method; add migration note in Claude Code section - INSTALL: replace rtk-rewrite.sh references with rtk hook claude, update flow diagram and uninstall docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
…aude - verify_hook() now checks ~/.claude/settings.json for "rtk hook claude" instead of looking for the rtk-rewrite.sh file + SHA-256 hash - Verified = rtk hook claude present in PreToolUse hooks - NoBaseline = legacy rtk-rewrite.sh detected (still works, needs migration) - NotInstalled = neither hook registered - run_verify() output updated to reflect settings.json check - runtime_check() simplified (all settings.json states are non-blocking) - show_claude_config() (rtk init --show) now reports hook presence from settings.json instead of the old file-based integrity check - verify_hook_at() + read_stored_hash() gated #[cfg(test)] (legacy SHA-256 logic only needed by existing hash-based tests) - 7 new tests for verify_hook_in_settings() covering: native hook, legacy hook, missing file, empty object, no hooks key, no PreToolUse, unrelated cmd Fixes: rtk verify reporting "SKIP RTK hook not installed" even when rtk hook claude is registered; rtk init --show showing stale hook file status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
CLAUDE.md is the AI guidance file, not user-facing docs. Python/Go command coverage check should only apply to README.md. The check was causing false failures on develop itself. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com>
79ec176 to
4f1a317
Compare
Problem
`rtk init -g` installs a shell script (`rtk-rewrite.sh`) as a Claude Code PreToolUse hook. That script has a hard dependency on `jq` to parse and build JSON.
When `jq` is not installed, the hook silently exits 0 with no output — Claude Code sees no rewrite, no error, nothing. Users end up with RTK installed but zero commands being rewritten, with no indication of why.
The warning is printed to stderr inside the hook process, which is swallowed by Claude Code's hook runner. So the user never sees it:
```bash
if ! command -v jq &>/dev/null; then
echo "[rtk] WARNING: jq is not installed..." >&2
exit 0 # silent passthrough — user never knows
fi
```
This affects anyone on macOS who did not install jq via Homebrew, and Linux users on minimal installs.
Solution
Replace the shell script with a native Rust subcommand `rtk hook claude` that uses `serde_json` for JSON parsing. No external dependencies — if `rtk` is in PATH, the hook works.
The hook command registered in `settings.json` changes from:
```json
{ "command": "/Users/you/.claude/hooks/rtk-rewrite.sh" }
```
to:
```json
{ "command": "rtk hook claude" }
```
Changes
Upgrade path for existing users
```bash
cargo install rtk # or: brew upgrade rtk-ai/tap/rtk
rtk init -g # auto-migrates rtk-rewrite.sh → rtk hook claude in settings.json
Restart Claude Code
```
Test plan
Closes #430. Implements #154.
🤖 Generated with Claude Code