From cd26117afc699410269d5d74290c37707d3e5786 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Thu, 26 Mar 2026 15:19:03 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat(hook):=20add=20native=20rtk=20hook=20c?= =?UTF-8?q?laude=20=E2=80=94=20eliminates=20jq=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Florian BRUNIAUX --- src/cmds/ruby/rake_cmd.rs | 2 +- src/hooks/hook_cmd.rs | 153 +++++++++++++++++++++++ src/hooks/init.rs | 256 +++++++++++++++++++++++--------------- src/hooks/integrity.rs | 1 + src/main.rs | 5 + 5 files changed, 316 insertions(+), 101 deletions(-) diff --git a/src/cmds/ruby/rake_cmd.rs b/src/cmds/ruby/rake_cmd.rs index e116c8f9..62bafb3a 100644 --- a/src/cmds/ruby/rake_cmd.rs +++ b/src/cmds/ruby/rake_cmd.rs @@ -15,7 +15,7 @@ use anyhow::{Context, Result}; /// `rails test` which handles single files, multiple files, and line-number /// syntax (`file.rb:15`) natively. fn select_runner(args: &[String]) -> (&'static str, Vec) { - let has_test_subcommand = args.first().map_or(false, |a| a == "test"); + let has_test_subcommand = args.first().is_some_and(|a| a == "test"); if !has_test_subcommand { return ("rake", args.to_vec()); } diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 8eb4e2fa..9de6fcde 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -141,6 +141,68 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { // ── Gemini hook ─────────────────────────────────────────────── +// ── Claude Code hook ────────────────────────────────────────── + +/// Run the Claude Code PreToolUse hook. +/// Reads JSON from stdin, rewrites Bash tool commands to rtk equivalents. +/// No external dependencies — unlike rtk-rewrite.sh which required jq. +pub fn run_claude() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read stdin")?; + + let input = input.trim(); + if input.is_empty() { + return Ok(()); + } + + let v: Value = match serde_json::from_str(input) { + Ok(v) => v, + Err(e) => { + eprintln!("[rtk hook] Failed to parse JSON input: {e}"); + return Ok(()); + } + }; + + // Only handle Bash tool + let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); + if tool_name != "Bash" { + return Ok(()); + } + + let cmd = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap_or(""); + + if cmd.is_empty() { + return Ok(()); + } + + let rewritten = match get_rewritten(cmd) { + Some(r) => r, + None => return Ok(()), + }; + + // Preserve all tool_input fields (description, etc.), only update command + let mut updated_input = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); + if let Some(obj) = updated_input.as_object_mut() { + obj.insert("command".to_string(), Value::String(rewritten)); + } + + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": updated_input + } + }); + println!("{output}"); + Ok(()) +} + +// ── Gemini CLI hook ─────────────────────────────────────────── + /// Run the Gemini CLI BeforeTool hook. /// Reads JSON from stdin, rewrites shell commands to rtk equivalents, /// outputs JSON to stdout in Gemini CLI format. @@ -332,4 +394,95 @@ mod tests { Some("RUST_LOG=debug rtk cargo test".into()) ); } + + // --- Claude Code hook --- + + fn claude_input(cmd: &str) -> String { + json!({ + "tool_name": "Bash", + "tool_input": { "command": cmd, "description": "Run command" } + }) + .to_string() + } + + #[test] + fn test_claude_rewrites_git_status() { + let input = claude_input("git status"); + let v: Value = serde_json::from_str(&input).unwrap(); + let cmd = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap(); + let rewritten = get_rewritten(cmd).expect("should rewrite git status"); + assert_eq!(rewritten, "rtk git status"); + } + + #[test] + fn test_claude_no_rewrite_for_unknown_command() { + let input = claude_input("htop"); + let v: Value = serde_json::from_str(&input).unwrap(); + let cmd = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap(); + assert!(get_rewritten(cmd).is_none()); + } + + #[test] + fn test_claude_no_rewrite_for_already_rtk() { + let input = claude_input("rtk git status"); + let v: Value = serde_json::from_str(&input).unwrap(); + let cmd = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap(); + assert!(get_rewritten(cmd).is_none()); + } + + #[test] + fn test_claude_preserves_tool_input_fields() { + // updated_input must preserve 'description' field alongside rewritten command + let v: Value = json!({ + "tool_name": "Bash", + "tool_input": { "command": "git status", "description": "Check status" } + }); + let cmd = v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .unwrap(); + let rewritten = get_rewritten(cmd).unwrap(); + + let mut updated_input = v.get("tool_input").cloned().unwrap(); + updated_input + .as_object_mut() + .unwrap() + .insert("command".to_string(), Value::String(rewritten)); + + assert_eq!(updated_input["command"], "rtk git status"); + assert_eq!(updated_input["description"], "Check status"); + } + + #[test] + fn test_claude_ignores_non_bash_tool() { + let v: Value = json!({ "tool_name": "Edit", "tool_input": { "command": "git status" } }); + let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); + assert_ne!(tool_name, "Bash"); + } + + #[test] + fn test_claude_output_format() { + let rewritten = "rtk git status".to_string(); + let updated_input = json!({ "command": rewritten, "description": "Run" }); + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": updated_input + } + }); + assert_eq!(output["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + assert_eq!( + output["hookSpecificOutput"]["updatedInput"]["command"], + "rtk git status" + ); + } } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 64115a1d..52937b9b 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -8,9 +8,13 @@ use tempfile::NamedTempFile; use super::integrity; -// Embedded hook script (guards before set -euo pipefail) +// Embedded hook script - test-only: verifies the legacy shell script still has required guards +#[cfg(test)] const REWRITE_HOOK: &str = include_str!("../../hooks/claude/rtk-rewrite.sh"); +// Native Rust hook command: no jq dependency, no shell script file to write +const CLAUDE_HOOK_CMD: &str = "rtk hook claude"; + // Embedded Cursor hook script (preToolUse format) const CURSOR_REWRITE_HOOK: &str = include_str!("../../hooks/cursor/rtk-rewrite.sh"); @@ -289,62 +293,6 @@ pub fn run( Ok(()) } -/// Prepare hook directory and return paths (hook_dir, hook_path) -fn prepare_hook_paths() -> Result<(PathBuf, PathBuf)> { - let claude_dir = resolve_claude_dir()?; - let hook_dir = claude_dir.join("hooks"); - fs::create_dir_all(&hook_dir) - .with_context(|| format!("Failed to create hook directory: {}", hook_dir.display()))?; - let hook_path = hook_dir.join("rtk-rewrite.sh"); - Ok((hook_dir, hook_path)) -} - -/// Write hook file if missing or outdated, return true if changed -#[cfg(unix)] -fn ensure_hook_installed(hook_path: &Path, verbose: u8) -> Result { - let changed = if hook_path.exists() { - let existing = fs::read_to_string(hook_path) - .with_context(|| format!("Failed to read existing hook: {}", hook_path.display()))?; - - if existing == REWRITE_HOOK { - if verbose > 0 { - eprintln!("Hook already up to date: {}", hook_path.display()); - } - false - } else { - fs::write(hook_path, REWRITE_HOOK) - .with_context(|| format!("Failed to write hook to {}", hook_path.display()))?; - if verbose > 0 { - eprintln!("Updated hook: {}", hook_path.display()); - } - true - } - } else { - fs::write(hook_path, REWRITE_HOOK) - .with_context(|| format!("Failed to write hook to {}", hook_path.display()))?; - if verbose > 0 { - eprintln!("Created hook: {}", hook_path.display()); - } - true - }; - - // Set executable permissions - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(hook_path, fs::Permissions::from_mode(0o755)) - .with_context(|| format!("Failed to set hook permissions: {}", hook_path.display()))?; - - // Store SHA-256 hash for runtime integrity verification. - // Always store (idempotent) to ensure baseline exists even for - // hooks installed before integrity checks were added. - integrity::store_hash(hook_path) - .with_context(|| format!("Failed to store integrity hash for {}", hook_path.display()))?; - if verbose > 0 && changed { - eprintln!("Stored integrity hash for hook"); - } - - Ok(changed) -} - /// Idempotent file write: create or update if content differs fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8) -> Result { if path.exists() { @@ -430,13 +378,13 @@ fn prompt_user_consent(settings_path: &Path) -> Result { } /// Print manual instructions for settings.json patching -fn print_manual_instructions(hook_path: &Path, include_opencode: bool) { +fn print_manual_instructions(hook_command: &str, include_opencode: bool) { println!("\n MANUAL STEP: Add this to ~/.claude/settings.json:"); println!(" {{"); println!(" \"hooks\": {{ \"PreToolUse\": [{{"); println!(" \"matcher\": \"Bash\","); println!(" \"hooks\": [{{ \"type\": \"command\","); - println!(" \"command\": \"{}\"", hook_path.display()); + println!(" \"command\": \"{hook_command}\""); println!(" }}]"); println!(" }}]}}"); println!(" }}"); @@ -686,18 +634,16 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result> { } /// Orchestrator: patch settings.json with RTK hook -/// Handles reading, checking, prompting, merging, backing up, and atomic writing +/// Handles reading, checking, prompting, merging, backing up, and atomic writing. +/// Migrates old rtk-rewrite.sh entries to the native `rtk hook claude` command. fn patch_settings_json( - hook_path: &Path, + hook_command: &str, mode: PatchMode, verbose: u8, include_opencode: bool, ) -> Result { let claude_dir = resolve_claude_dir()?; let settings_path = claude_dir.join("settings.json"); - let hook_command = hook_path - .to_str() - .context("Hook path contains invalid UTF-8")?; // Read or create settings.json let mut root = if settings_path.exists() { @@ -714,6 +660,23 @@ fn patch_settings_json( serde_json::json!({}) }; + // 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); + } + // Check idempotency if hook_already_present(&root, hook_command) { if verbose > 0 { @@ -725,12 +688,12 @@ fn patch_settings_json( // Handle mode match mode { PatchMode::Skip => { - print_manual_instructions(hook_path, include_opencode); + print_manual_instructions(hook_command, include_opencode); return Ok(PatchResult::Skipped); } PatchMode::Ask => { if !prompt_user_consent(&settings_path)? { - print_manual_instructions(hook_path, include_opencode); + print_manual_instructions(hook_command, include_opencode); return Ok(PatchResult::Declined); } } @@ -839,8 +802,8 @@ fn insert_hook_entry(root: &mut serde_json::Value, hook_command: &str) { })); } -/// Check if RTK hook is already present in settings.json -/// Matches on rtk-rewrite.sh substring to handle different path formats +/// Check if RTK hook is already present in settings.json. +/// Detects both legacy rtk-rewrite.sh entries and the native `rtk hook claude` command. fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { let pre_tool_use_array = match root .get("hooks") @@ -857,12 +820,43 @@ fn hook_already_present(root: &serde_json::Value, hook_command: &str) -> bool { .flatten() .filter_map(|hook| hook.get("command")?.as_str()) .any(|cmd| { - // Exact match OR both contain rtk-rewrite.sh - cmd == hook_command - || (cmd.contains("rtk-rewrite.sh") && hook_command.contains("rtk-rewrite.sh")) + cmd == hook_command || cmd.contains("rtk-rewrite.sh") || cmd.contains("rtk hook claude") }) } +/// Replace legacy rtk-rewrite.sh hook entries with the native command. +/// Returns true if a migration was performed (settings.json needs rewriting). +fn migrate_legacy_hook(root: &mut serde_json::Value, new_command: &str) -> bool { + let pre_tool_use_array = match root + .get_mut("hooks") + .and_then(|h| h.get_mut("PreToolUse")) + .and_then(|p| p.as_array_mut()) + { + Some(arr) => arr, + None => return false, + }; + + let mut migrated = false; + for entry in pre_tool_use_array.iter_mut() { + if let Some(hooks) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) { + for hook in hooks.iter_mut() { + if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) { + if cmd.contains("rtk-rewrite.sh") { + if let Some(obj) = hook.as_object_mut() { + obj.insert( + "command".to_string(), + serde_json::Value::String(new_command.to_string()), + ); + migrated = true; + } + } + } + } + } + } + migrated +} + /// Default mode: hook + slim RTK.md + @RTK.md reference #[cfg(not(unix))] fn run_default_mode( @@ -895,11 +889,7 @@ fn run_default_mode( let rtk_md_path = claude_dir.join("RTK.md"); let claude_md_path = claude_dir.join("CLAUDE.md"); - // 1. Prepare hook directory and install hook - let (_hook_dir, hook_path) = prepare_hook_paths()?; - let hook_changed = ensure_hook_installed(&hook_path, verbose)?; - - // 2. Write RTK.md + // 1. Write RTK.md write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; let opencode_plugin_path = if install_opencode { @@ -910,17 +900,12 @@ fn run_default_mode( None }; - // 3. Patch CLAUDE.md (add @RTK.md, migrate if needed) + // 2. Patch CLAUDE.md (add @RTK.md, migrate if needed) let migrated = patch_claude_md(&claude_md_path, verbose)?; - // 4. Print success message - let hook_status = if hook_changed { - "installed/updated" - } else { - "already up to date" - }; - println!("\nRTK hook {} (global).\n", hook_status); - println!(" Hook: {}", hook_path.display()); + // 3. Print success message + println!("\nRTK installed (global).\n"); + println!(" Hook: {CLAUDE_HOOK_CMD} (built-in, no dependencies)"); println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); if let Some(path) = &opencode_plugin_path { println!(" OpenCode: {}", path.display()); @@ -932,8 +917,8 @@ fn run_default_mode( println!(" replaced with @RTK.md (10 lines)"); } - // 5. Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose, install_opencode)?; + // 4. Patch settings.json (also migrates legacy rtk-rewrite.sh if present) + let patch_result = patch_settings_json(CLAUDE_HOOK_CMD, patch_mode, verbose, install_opencode)?; // Report result match patch_result { @@ -1034,10 +1019,6 @@ fn run_hook_only_mode( return Ok(()); } - // Prepare and install hook - let (_hook_dir, hook_path) = prepare_hook_paths()?; - let hook_changed = ensure_hook_installed(&hook_path, verbose)?; - let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; ensure_opencode_plugin_installed(&path, verbose)?; @@ -1046,13 +1027,8 @@ fn run_hook_only_mode( None }; - let hook_status = if hook_changed { - "installed/updated" - } else { - "already up to date" - }; - println!("\nRTK hook {} (hook-only mode).\n", hook_status); - println!(" Hook: {}", hook_path.display()); + println!("\nRTK installed (hook-only mode).\n"); + println!(" Hook: {CLAUDE_HOOK_CMD} (built-in, no dependencies)"); if let Some(path) = &opencode_plugin_path { println!(" OpenCode: {}", path.display()); } @@ -1060,8 +1036,8 @@ fn run_hook_only_mode( " Note: No RTK.md created. Claude won't know about meta commands (gain, discover, proxy)." ); - // Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose, install_opencode)?; + // Patch settings.json (also migrates legacy rtk-rewrite.sh if present) + let patch_result = patch_settings_json(CLAUDE_HOOK_CMD, patch_mode, verbose, install_opencode)?; // Report result match patch_result { @@ -2777,6 +2753,86 @@ More notes assert!(!hook_already_present(&json_content, hook_command)); } + #[test] + fn test_hook_already_present_native_command() { + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "rtk hook claude" }] + }] + } + }); + assert!(hook_already_present(&json_content, "rtk hook claude")); + } + + #[test] + fn test_hook_already_present_legacy_detected_by_native_check() { + // When checking for "rtk hook claude", legacy rtk-rewrite.sh should also return true + // (prevents duplicate install when user has old hook) + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/Users/user/.claude/hooks/rtk-rewrite.sh" + }] + }] + } + }); + assert!(hook_already_present(&json_content, "rtk hook claude")); + } + + #[test] + fn test_migrate_legacy_hook_replaces_command() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/Users/stephane/.claude/hooks/rtk-rewrite.sh" + }] + }] + } + }); + + let migrated = migrate_legacy_hook(&mut root, "rtk hook claude"); + assert!(migrated, "should have migrated"); + + let cmd = root + .pointer("/hooks/PreToolUse/0/hooks/0/command") + .and_then(|v| v.as_str()) + .unwrap(); + assert_eq!(cmd, "rtk hook claude"); + } + + #[test] + fn test_migrate_legacy_hook_noop_when_already_native() { + let mut root = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "rtk hook claude" }] + }] + } + }); + + let migrated = migrate_legacy_hook(&mut root, "rtk hook claude"); + assert!( + !migrated, + "nothing to migrate when already using native command" + ); + } + + #[test] + fn test_migrate_legacy_hook_noop_when_empty() { + let mut root = serde_json::json!({}); + let migrated = migrate_legacy_hook(&mut root, "rtk hook claude"); + assert!(!migrated); + } + // Tests for insert_hook_entry() #[test] fn test_insert_hook_entry_empty_root() { diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 96d26368..49b3e3b2 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -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)] pub fn store_hash(hook_path: &Path) -> Result<()> { let hash = compute_hash(hook_path)?; let hash_file = hash_path(hook_path); diff --git a/src/main.rs b/src/main.rs index 50a39ce5..581d6e6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -685,6 +685,8 @@ enum Commands { #[derive(Subcommand)] enum HookCommands { + /// Process Claude Code PreToolUse hook (reads JSON from stdin, no dependencies) + Claude, /// Process Gemini CLI BeforeTool hook (reads JSON from stdin) Gemini, /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) @@ -2054,6 +2056,9 @@ fn main() -> Result<()> { } Commands::Hook { command } => match command { + HookCommands::Claude => { + hooks::hook_cmd::run_claude()?; + } HookCommands::Gemini => { hooks::hook_cmd::run_gemini()?; } From d6510bec2962772079fd0699ebc786bab8cc15b2 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Thu, 26 Mar 2026 15:40:43 +0100 Subject: [PATCH 2/5] test(hook): refactor claude hook tests to use process_claude_hook() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract core Claude Code hook logic into process_claude_hook(input: &str) -> Option 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 Signed-off-by: Florian BRUNIAUX --- src/hooks/hook_cmd.rs | 185 +++++++++++++++++++++++------------------- src/hooks/init.rs | 27 ++++-- 2 files changed, 120 insertions(+), 92 deletions(-) diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 9de6fcde..bcbbb11a 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -143,32 +143,27 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { // ── Claude Code hook ────────────────────────────────────────── -/// Run the Claude Code PreToolUse hook. -/// Reads JSON from stdin, rewrites Bash tool commands to rtk equivalents. -/// No external dependencies — unlike rtk-rewrite.sh which required jq. -pub fn run_claude() -> Result<()> { - let mut input = String::new(); - io::stdin() - .read_to_string(&mut input) - .context("Failed to read stdin")?; - +/// Core Claude Code hook logic: parse JSON input and return hook response JSON string. +/// Returns `Some(json)` if the command was rewritten, `None` to pass through silently. +/// Extracted for testability — `run_claude()` is the stdin/stdout wrapper. +pub(crate) fn process_claude_hook(input: &str) -> Option { let input = input.trim(); if input.is_empty() { - return Ok(()); + return None; } let v: Value = match serde_json::from_str(input) { Ok(v) => v, Err(e) => { eprintln!("[rtk hook] Failed to parse JSON input: {e}"); - return Ok(()); + return None; } }; // Only handle Bash tool let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); if tool_name != "Bash" { - return Ok(()); + return None; } let cmd = v @@ -177,13 +172,10 @@ pub fn run_claude() -> Result<()> { .unwrap_or(""); if cmd.is_empty() { - return Ok(()); + return None; } - let rewritten = match get_rewritten(cmd) { - Some(r) => r, - None => return Ok(()), - }; + let rewritten = get_rewritten(cmd)?; // Preserve all tool_input fields (description, etc.), only update command let mut updated_input = v.get("tool_input").cloned().unwrap_or_else(|| json!({})); @@ -191,13 +183,29 @@ pub fn run_claude() -> Result<()> { obj.insert("command".to_string(), Value::String(rewritten)); } - let output = json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "updatedInput": updated_input - } - }); - println!("{output}"); + Some( + json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": updated_input + } + }) + .to_string(), + ) +} + +/// Run the Claude Code PreToolUse hook. +/// Reads JSON from stdin, rewrites Bash tool commands to rtk equivalents. +/// No external dependencies — unlike rtk-rewrite.sh which required jq. +pub fn run_claude() -> Result<()> { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("Failed to read stdin")?; + + if let Some(output) = process_claude_hook(&input) { + println!("{output}"); + } Ok(()) } @@ -395,9 +403,9 @@ mod tests { ); } - // --- Claude Code hook --- + // --- Claude Code hook (process_claude_hook) --- - fn claude_input(cmd: &str) -> String { + fn claude_json(cmd: &str) -> String { json!({ "tool_name": "Bash", "tool_input": { "command": cmd, "description": "Run command" } @@ -405,84 +413,95 @@ mod tests { .to_string() } + fn parse_hook_output(json_str: &str) -> Value { + serde_json::from_str(json_str).expect("hook output must be valid JSON") + } + #[test] fn test_claude_rewrites_git_status() { - let input = claude_input("git status"); - let v: Value = serde_json::from_str(&input).unwrap(); - let cmd = v - .pointer("/tool_input/command") - .and_then(|c| c.as_str()) - .unwrap(); - let rewritten = get_rewritten(cmd).expect("should rewrite git status"); - assert_eq!(rewritten, "rtk git status"); + let output = process_claude_hook(&claude_json("git status")).expect("should rewrite"); + let v = parse_hook_output(&output); + assert_eq!( + v["hookSpecificOutput"]["updatedInput"]["command"], + "rtk git status" + ); } #[test] - fn test_claude_no_rewrite_for_unknown_command() { - let input = claude_input("htop"); - let v: Value = serde_json::from_str(&input).unwrap(); - let cmd = v - .pointer("/tool_input/command") - .and_then(|c| c.as_str()) - .unwrap(); - assert!(get_rewritten(cmd).is_none()); + fn test_claude_rewrites_cargo_test() { + let output = process_claude_hook(&claude_json("cargo test --all")).expect("should rewrite"); + let v = parse_hook_output(&output); + assert_eq!( + v["hookSpecificOutput"]["updatedInput"]["command"], + "rtk cargo test --all" + ); } #[test] - fn test_claude_no_rewrite_for_already_rtk() { - let input = claude_input("rtk git status"); - let v: Value = serde_json::from_str(&input).unwrap(); - let cmd = v - .pointer("/tool_input/command") - .and_then(|c| c.as_str()) - .unwrap(); - assert!(get_rewritten(cmd).is_none()); + fn test_claude_no_output_for_unknown_command() { + assert!(process_claude_hook(&claude_json("htop")).is_none()); } #[test] - fn test_claude_preserves_tool_input_fields() { - // updated_input must preserve 'description' field alongside rewritten command - let v: Value = json!({ - "tool_name": "Bash", - "tool_input": { "command": "git status", "description": "Check status" } - }); - let cmd = v - .pointer("/tool_input/command") - .and_then(|c| c.as_str()) - .unwrap(); - let rewritten = get_rewritten(cmd).unwrap(); + fn test_claude_no_output_for_already_rtk() { + assert!(process_claude_hook(&claude_json("rtk git status")).is_none()); + } - let mut updated_input = v.get("tool_input").cloned().unwrap(); - updated_input - .as_object_mut() - .unwrap() - .insert("command".to_string(), Value::String(rewritten)); + #[test] + fn test_claude_no_output_for_non_bash_tool() { + let input = + json!({ "tool_name": "Edit", "tool_input": { "command": "git status" } }).to_string(); + assert!(process_claude_hook(&input).is_none()); + } - assert_eq!(updated_input["command"], "rtk git status"); - assert_eq!(updated_input["description"], "Check status"); + #[test] + fn test_claude_no_output_for_empty_input() { + assert!(process_claude_hook("").is_none()); + assert!(process_claude_hook(" ").is_none()); } #[test] - fn test_claude_ignores_non_bash_tool() { - let v: Value = json!({ "tool_name": "Edit", "tool_input": { "command": "git status" } }); - let tool_name = v.get("tool_name").and_then(|t| t.as_str()).unwrap_or(""); - assert_ne!(tool_name, "Bash"); + fn test_claude_no_output_for_invalid_json() { + assert!(process_claude_hook("not json at all").is_none()); } #[test] - fn test_claude_output_format() { - let rewritten = "rtk git status".to_string(); - let updated_input = json!({ "command": rewritten, "description": "Run" }); - let output = json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "updatedInput": updated_input - } - }); - assert_eq!(output["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + fn test_claude_preserves_description_field() { + let input = json!({ + "tool_name": "Bash", + "tool_input": { "command": "git status", "description": "Check repo status" } + }) + .to_string(); + let output = process_claude_hook(&input).expect("should rewrite"); + let v = parse_hook_output(&output); assert_eq!( - output["hookSpecificOutput"]["updatedInput"]["command"], + v["hookSpecificOutput"]["updatedInput"]["command"], "rtk git status" ); + assert_eq!( + v["hookSpecificOutput"]["updatedInput"]["description"], + "Check repo status" + ); + } + + #[test] + fn test_claude_output_schema() { + let output = process_claude_hook(&claude_json("git status")).expect("should rewrite"); + let v = parse_hook_output(&output); + // Verify expected Claude Code PreToolUse hook response shape + assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse"); + assert!(v["hookSpecificOutput"]["updatedInput"].is_object()); + // Must NOT include permissionDecision (Claude Code infers allow from updatedInput presence) + assert!(v["hookSpecificOutput"]["permissionDecision"].is_null()); + } + + #[test] + fn test_claude_heredoc_passthrough() { + let input = json!({ + "tool_name": "Bash", + "tool_input": { "command": "cat <<'EOF'\nhello\nEOF" } + }) + .to_string(); + assert!(process_claude_hook(&input).is_none()); } } diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 52937b9b..078cc9b8 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -2476,24 +2476,33 @@ More content"#; assert_eq!(result, input); } + // rtk init -g now installs "rtk hook claude" (native Rust, no jq) rather than a shell script. + // The legacy rtk-rewrite.sh is kept in the repo for reference and verified here. + + #[test] + fn test_claude_hook_cmd_constant() { + // The command registered in settings.json must match the CLI subcommand exactly + assert_eq!(CLAUDE_HOOK_CMD, "rtk hook claude"); + } + #[test] #[cfg(unix)] - fn test_default_mode_creates_hook_and_rtk_md() { + fn test_legacy_hook_script_has_proper_guards() { + // Verify the legacy shell script still has required guards (used by manual installs) + assert!(REWRITE_HOOK.contains("command -v rtk")); + assert!(REWRITE_HOOK.contains("command -v jq")); + // rtk-rewrite.sh can be executed directly if jq is available let temp = TempDir::new().unwrap(); let hook_path = temp.path().join("rtk-rewrite.sh"); - let rtk_md_path = temp.path().join("RTK.md"); - fs::write(&hook_path, REWRITE_HOOK).unwrap(); - fs::write(&rtk_md_path, RTK_SLIM).unwrap(); - use std::os::unix::fs::PermissionsExt; fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap(); - assert!(hook_path.exists()); - assert!(rtk_md_path.exists()); - let metadata = fs::metadata(&hook_path).unwrap(); - assert!(metadata.permissions().mode() & 0o111 != 0); + assert!( + metadata.permissions().mode() & 0o111 != 0, + "hook must be executable" + ); } #[test] From cf05f3ddc3cff4e8c4e684045f4a4a26b31d01b4 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Thu, 26 Mar 2026 16:28:48 +0100 Subject: [PATCH 3/5] docs: update README, CHANGELOG, INSTALL for rtk hook claude - 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 Signed-off-by: Florian BRUNIAUX --- CHANGELOG.md | 6 ++++++ INSTALL.md | 7 ++++--- README.md | 4 +++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e98489a2..cf5019ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Features + +* **hook:** add `rtk hook claude` native Rust subcommand — replaces `rtk-rewrite.sh`, no `jq` dependency required ([#862](https://github.com/rtk-ai/rtk/pull/862)) +* **init:** `rtk init -g` now installs `rtk hook claude` (native binary) instead of the shell script; auto-migrates existing `rtk-rewrite.sh` entries on re-run ([#862](https://github.com/rtk-ai/rtk/pull/862)) + ### Bug Fixes +* **hook:** fix silent failure when `jq` is not installed — `rtk-rewrite.sh` would exit 0 without rewriting any commands ([#430](https://github.com/rtk-ai/rtk/issues/430), [#862](https://github.com/rtk-ai/rtk/pull/862)) * **diff:** correct truncation overflow count in condense_unified_diff ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([5399f83](https://github.com/rtk-ai/rtk/commit/5399f83)) * **git:** replace vague truncation markers with exact counts in log and grep output ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([185fb97](https://github.com/rtk-ai/rtk/commit/185fb97)) diff --git a/INSTALL.md b/INSTALL.md index 98457d09..0b78f6c1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -93,11 +93,12 @@ rtk gain # MUST show token savings, not "command not found" ```bash rtk init -g -# → Installs hook to ~/.claude/hooks/rtk-rewrite.sh +# → Registers "rtk hook claude" in settings.json (native Rust, no jq required) # → Creates ~/.claude/RTK.md (10 lines, meta commands only) # → Adds @RTK.md reference to ~/.claude/CLAUDE.md # → Prompts: "Patch settings.json? [y/N]" # → If yes: patches + creates backup (~/.claude/settings.json.bak) +# → If rtk-rewrite.sh was previously installed: auto-migrates to native hook # Automated alternatives: rtk init -g --auto-patch # Patch without prompting @@ -113,7 +114,7 @@ rtk init --show # Check hook is installed and executable Claude Code's hook registry. RTK adds a PreToolUse hook that rewrites commands transparently. Without this, Claude won't invoke the hook automatically. ``` - Claude Code settings.json rtk-rewrite.sh RTK binary + Claude Code settings.json rtk hook claude RTK binary │ │ │ │ │ "git status" │ │ │ │ ──────────────────►│ │ │ @@ -247,7 +248,7 @@ rtk vitest run rtk init -g --uninstall # What gets removed: -# - Hook: ~/.claude/hooks/rtk-rewrite.sh +# - Hook: "rtk hook claude" entry from settings.json # - Context: ~/.claude/RTK.md # - Reference: @RTK.md line from ~/.claude/CLAUDE.md # - Registration: RTK hook entry from settings.json diff --git a/README.md b/README.md index 76d56520..3535b272 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ RTK supports 10 AI coding tools. Each integration transparently rewrites shell c | Tool | Install | Method | |------|---------|--------| -| **Claude Code** | `rtk init -g` | PreToolUse hook (bash) | +| **Claude Code** | `rtk init -g` | PreToolUse hook (`rtk hook claude`) — no `jq` required | | **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook (`rtk hook copilot`) — transparent rewrite | | **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) | | **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) | @@ -321,6 +321,8 @@ rtk init --show # Verify installation rtk init -g --uninstall # Remove ``` +Uses `rtk hook claude` — a native Rust binary that reads JSON from stdin and rewrites Bash tool commands. No external dependencies (`jq` not required). If you previously installed via `rtk-rewrite.sh`, re-running `rtk init -g` auto-migrates to the native hook. + ### GitHub Copilot (VS Code + CLI) ```bash From edf0e4e74e9e33c70523ac9705139f5b1438e29f Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Thu, 26 Mar 2026 19:02:46 +0100 Subject: [PATCH 4/5] fix(integrity): adapt verify_hook + rtk verify for native rtk hook claude - 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 Signed-off-by: Florian BRUNIAUX --- src/hooks/init.rs | 82 +++--------- src/hooks/integrity.rs | 285 ++++++++++++++++++++++++++++------------- 2 files changed, 210 insertions(+), 157 deletions(-) diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 078cc9b8..d5ec3c7a 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -1790,54 +1790,24 @@ fn show_claude_config() -> Result<()> { println!("rtk Configuration:\n"); - // Check hook - if hook_path.exists() { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let metadata = fs::metadata(&hook_path)?; - let perms = metadata.permissions(); - let is_executable = perms.mode() & 0o111 != 0; - - let hook_content = fs::read_to_string(&hook_path)?; - let has_guards = - hook_content.contains("command -v rtk") && hook_content.contains("command -v jq"); - let is_thin_delegator = hook_content.contains("rtk rewrite"); - let hook_version = super::hook_check::parse_hook_version(&hook_content); - - if !is_executable { - println!( - "[warn] Hook: {} (NOT executable - run: chmod +x)", - hook_path.display() - ); - } else if !is_thin_delegator { - println!( - "[warn] Hook: {} (outdated — inline logic, not thin delegator)", - hook_path.display() - ); - println!( - " → Run `rtk init --global` to upgrade to the single source of truth hook" - ); - } else if is_executable && has_guards { - println!( - "[ok] Hook: {} (thin delegator, version {})", - hook_path.display(), - hook_version - ); - } else { - println!( - "[warn] Hook: {} (no guards - outdated)", - hook_path.display() - ); - } + // Check hook presence in settings.json + match integrity::verify_hook() { + Ok(integrity::IntegrityStatus::Verified) => { + println!("[ok] Hook: rtk hook claude registered in settings.json"); } - - #[cfg(not(unix))] - { - println!("[ok] Hook: {} (exists)", hook_path.display()); + Ok(integrity::IntegrityStatus::NoBaseline) => { + println!("[warn] Hook: legacy rtk-rewrite.sh detected (run: rtk init -g to migrate)"); + } + Ok(integrity::IntegrityStatus::NotInstalled) => { + println!("[--] Hook: not registered in settings.json (run: rtk init -g)"); + } + Ok(integrity::IntegrityStatus::Tampered { .. }) + | Ok(integrity::IntegrityStatus::OrphanedHash) => { + // Cannot occur from settings.json check + } + Err(_) => { + println!("[warn] Hook: settings.json check failed"); } - } else { - println!("[--] Hook: not found"); } // Check RTK.md @@ -1847,26 +1817,6 @@ fn show_claude_config() -> Result<()> { println!("[--] RTK.md: not found"); } - // Check hook integrity - match integrity::verify_hook_at(&hook_path) { - Ok(integrity::IntegrityStatus::Verified) => { - println!("[ok] Integrity: hook hash verified"); - } - Ok(integrity::IntegrityStatus::Tampered { .. }) => { - println!("[FAIL] Integrity: hook modified outside rtk init (run: rtk verify)"); - } - Ok(integrity::IntegrityStatus::NoBaseline) => { - println!("[warn] Integrity: no baseline hash (run: rtk init -g to establish)"); - } - Ok(integrity::IntegrityStatus::NotInstalled) - | Ok(integrity::IntegrityStatus::OrphanedHash) => { - // Don't show integrity line if hook isn't installed - } - Err(_) => { - println!("[warn] Integrity: check failed"); - } - } - // Check global CLAUDE.md if global_claude_md.exists() { let content = fs::read_to_string(&global_claude_md)?; diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index 49b3e3b2..d90518d2 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -1,18 +1,18 @@ -//! Detects if someone tampered with the installed hook file. +//! Hook integrity verification. //! -//! RTK installs a PreToolUse hook (`rtk-rewrite.sh`) that auto-approves -//! rewritten commands with `permissionDecision: "allow"`. Because this -//! hook bypasses Claude Code's permission prompts, any unauthorized -//! modification represents a command injection vector. +//! RTK installs a Claude Code PreToolUse hook (`rtk hook claude`) that +//! rewrites Bash tool commands to token-efficient `rtk` equivalents. +//! This module verifies the hook is registered in `~/.claude/settings.json` +//! so `rtk verify` and the runtime check can detect missing/legacy installs. //! -//! This module provides: -//! - SHA-256 hash computation and storage at install time -//! - Runtime verification before command execution -//! - Manual verification via `rtk verify` +//! SHA-256 helpers are kept for TOML filter integrity (used by trust.rs) +//! and for backward-compatible legacy uninstall of `rtk-rewrite.sh`. //! //! Reference: SA-2025-RTK-001 (Finding F-01) +//! Reference: SA-2025-RTK-001 (Finding F-01) use anyhow::{Context, Result}; +use serde_json::Value; use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; @@ -20,18 +20,19 @@ use std::path::{Path, PathBuf}; /// Filename for the stored hash (dotfile alongside hook) const HASH_FILENAME: &str = ".rtk-hook.sha256"; -/// Result of hook integrity verification +/// Result of hook integrity/presence verification #[derive(Debug, PartialEq)] pub enum IntegrityStatus { - /// Hash matches — hook is unmodified since last install/update + /// Native hook registered — `rtk hook claude` found in settings.json Verified, - /// Hash mismatch — hook has been modified outside of `rtk init` - Tampered { expected: String, actual: String }, - /// Hook exists but no stored hash (installed before integrity checks) + /// Legacy hook detected — `rtk-rewrite.sh` still in settings.json (needs migration) NoBaseline, - /// Neither hook nor hash file exist (RTK not installed) + /// RTK hook not registered in settings.json NotInstalled, - /// Hash file exists but hook was deleted + /// Legacy file-based states — kept for backward-compat match arms and tests + #[allow(dead_code)] + Tampered { expected: String, actual: String }, + #[allow(dead_code)] OrphanedHash, } @@ -116,16 +117,63 @@ pub fn remove_hash(hook_path: &Path) -> Result { Ok(true) } -/// Verify hook integrity against stored hash. +/// Verify hook presence by checking `~/.claude/settings.json`. /// /// Returns `IntegrityStatus` indicating the result. Callers decide /// how to handle each status (warn, block, ignore). pub fn verify_hook() -> Result { - let hook_path = resolve_hook_path()?; - verify_hook_at(&hook_path) + let settings_path = resolve_settings_path()?; + verify_hook_in_settings(&settings_path) +} + +/// Verify hook presence for a specific settings.json path (testable). +pub fn verify_hook_in_settings(settings_path: &Path) -> Result { + if !settings_path.exists() { + return Ok(IntegrityStatus::NotInstalled); + } + + let content = fs::read_to_string(settings_path) + .with_context(|| format!("Failed to read {}", settings_path.display()))?; + + if content.trim().is_empty() { + return Ok(IntegrityStatus::NotInstalled); + } + + let root: Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {} as JSON", settings_path.display()))?; + + if settings_has_hook(&root, "rtk hook claude") { + Ok(IntegrityStatus::Verified) + } else if settings_has_hook(&root, "rtk-rewrite.sh") { + // Legacy shell-script hook — still works but should migrate + Ok(IntegrityStatus::NoBaseline) + } else { + Ok(IntegrityStatus::NotInstalled) + } } -/// Verify hook integrity for a specific hook path (testable) +/// Returns true if the PreToolUse hooks array in a settings.json root +/// contains a command matching `needle`. +fn settings_has_hook(root: &Value, needle: &str) -> bool { + let arr = match root + .get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + arr.iter() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .filter_map(|hook| hook.get("command")?.as_str()) + .any(|cmd| cmd.contains(needle)) +} + +/// Verify hook integrity for a specific hook file path (legacy SHA-256 check). +/// Only used in tests. New installs use `verify_hook_in_settings` instead. +#[cfg(test)] pub fn verify_hook_at(hook_path: &Path) -> Result { let hash_file = hash_path(hook_path); @@ -153,6 +201,7 @@ pub fn verify_hook_at(hook_path: &Path) -> Result { /// /// Expects exact `sha256sum -c` format: `<64 hex> \n` /// Rejects malformed files rather than silently accepting them. +#[cfg(test)] fn read_stored_hash(path: &Path) -> Result { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read hash file: {}", path.display()))?; @@ -179,104 +228,55 @@ fn read_stored_hash(path: &Path) -> Result { Ok(hash.to_string()) } -/// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.sh) -pub fn resolve_hook_path() -> Result { +/// Resolve the default settings.json path (~/.claude/settings.json) +pub fn resolve_settings_path() -> Result { dirs::home_dir() - .map(|h| h.join(".claude").join("hooks").join("rtk-rewrite.sh")) + .map(|h| h.join(".claude").join("settings.json")) .context("Cannot determine home directory. Is $HOME set?") } /// Run integrity check and print results (for `rtk verify` subcommand) pub fn run_verify(verbose: u8) -> Result<()> { - let hook_path = resolve_hook_path()?; - let hash_file = hash_path(&hook_path); + let settings_path = resolve_settings_path()?; if verbose > 0 { - eprintln!("Hook: {}", hook_path.display()); - eprintln!("Hash: {}", hash_file.display()); + eprintln!("Settings: {}", settings_path.display()); } - match verify_hook_at(&hook_path)? { + match verify_hook_in_settings(&settings_path)? { IntegrityStatus::Verified => { - let hash = compute_hash(&hook_path)?; - println!("PASS hook integrity verified"); - println!(" sha256:{}", hash); - println!(" {}", hook_path.display()); - } - IntegrityStatus::Tampered { expected, actual } => { - eprintln!("FAIL hook integrity check FAILED"); - eprintln!(); - eprintln!(" Expected: {}", expected); - eprintln!(" Actual: {}", actual); - eprintln!(); - eprintln!(" The hook file has been modified outside of `rtk init`."); - eprintln!(" This could indicate tampering or a manual edit."); - eprintln!(); - eprintln!(" To restore: rtk init -g --auto-patch"); - eprintln!(" To inspect: cat {}", hook_path.display()); - std::process::exit(1); + println!("PASS rtk hook claude registered in settings.json"); + println!(" {}", settings_path.display()); } IntegrityStatus::NoBaseline => { - println!("WARN no baseline hash found"); - println!(" Hook exists but was installed before integrity checks."); - println!(" Run `rtk init -g` to establish baseline."); + println!("WARN legacy rtk-rewrite.sh hook detected"); + println!(" Run `rtk init -g` to migrate to the native hook (no jq required)."); } IntegrityStatus::NotInstalled => { - println!("SKIP RTK hook not installed"); + println!("SKIP RTK hook not found in settings.json"); println!(" Run `rtk init -g` to install."); } - IntegrityStatus::OrphanedHash => { - eprintln!("WARN hash file exists but hook is missing"); - eprintln!(" Run `rtk init -g` to reinstall."); + // Legacy file-based statuses — cannot occur from settings.json check + IntegrityStatus::Tampered { .. } | IntegrityStatus::OrphanedHash => { + println!("SKIP RTK hook not found in settings.json"); + println!(" Run `rtk init -g` to install."); } } Ok(()) } -/// Runtime integrity gate. Called at startup for operational commands. +/// Runtime presence check. Called at startup for operational commands. /// /// Behavior: /// - `Verified` / `NotInstalled` / `NoBaseline`: silent, continue -/// - `Tampered`: print warning to stderr, exit 1 -/// - `OrphanedHash`: warn to stderr, continue -/// -/// No env-var bypass is provided — if the hook is legitimately modified, -/// re-run `rtk init -g --auto-patch` to re-establish the baseline. +/// (not-installed is silent — hook is optional, just reduces token usage) +/// - `Tampered` / `OrphanedHash`: cannot occur from settings.json check, treat as pass-through pub fn runtime_check() -> Result<()> { - match verify_hook()? { - IntegrityStatus::Verified | IntegrityStatus::NotInstalled => { - // All good, proceed - } - IntegrityStatus::NoBaseline => { - // Installed before integrity checks — don't block - // Silently skip to avoid noise for users who haven't re-run init - } - IntegrityStatus::Tampered { expected, actual } => { - eprintln!("rtk: hook integrity check FAILED"); - eprintln!( - " Expected hash: {}...", - expected.get(..16).unwrap_or(&expected) - ); - eprintln!( - " Actual hash: {}...", - actual.get(..16).unwrap_or(&actual) - ); - eprintln!(); - eprintln!(" The hook at ~/.claude/hooks/rtk-rewrite.sh has been modified."); - eprintln!(" This may indicate tampering. RTK will not execute."); - eprintln!(); - eprintln!(" To restore: rtk init -g --auto-patch"); - eprintln!(" To inspect: rtk verify"); - std::process::exit(1); - } - IntegrityStatus::OrphanedHash => { - eprintln!("rtk: warning: hash file exists but hook is missing"); - eprintln!(" Run `rtk init -g` to reinstall."); - // Don't block — hook is gone, nothing to exploit - } - } - + // All statuses from the settings.json check are non-blocking — we never + // want to prevent the user from running RTK commands just because the + // hook isn't registered. + let _ = verify_hook()?; Ok(()) } @@ -535,4 +535,107 @@ mod tests { assert_eq!(parts[0].len(), 64); assert_eq!(parts[1], "rtk-rewrite.sh"); } + + // --- settings.json-based hook presence tests --- + + fn make_settings(hooks_json: &str) -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("settings.json"); + fs::write(&path, hooks_json).unwrap(); + dir + } + + fn native_settings() -> &'static str { + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "rtk hook claude" }] + } + ] + } +}"# + } + + fn legacy_settings() -> &'static str { + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "/home/user/.claude/hooks/rtk-rewrite.sh" }] + } + ] + } +}"# + } + + #[test] + fn test_verify_native_hook_in_settings() { + let dir = make_settings(native_settings()); + let path = dir.path().join("settings.json"); + let status = verify_hook_in_settings(&path).unwrap(); + assert_eq!(status, IntegrityStatus::Verified); + } + + #[test] + fn test_verify_legacy_hook_in_settings() { + let dir = make_settings(legacy_settings()); + let path = dir.path().join("settings.json"); + let status = verify_hook_in_settings(&path).unwrap(); + assert_eq!(status, IntegrityStatus::NoBaseline); + } + + #[test] + fn test_verify_not_installed_empty_settings() { + let dir = make_settings(r#"{}"#); + let path = dir.path().join("settings.json"); + let status = verify_hook_in_settings(&path).unwrap(); + assert_eq!(status, IntegrityStatus::NotInstalled); + } + + #[test] + fn test_verify_not_installed_missing_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("settings.json"); + // File does not exist + let status = verify_hook_in_settings(&path).unwrap(); + assert_eq!(status, IntegrityStatus::NotInstalled); + } + + #[test] + fn test_verify_not_installed_no_hooks_key() { + let dir = make_settings(r#"{ "permissions": {} }"#); + let path = dir.path().join("settings.json"); + let status = verify_hook_in_settings(&path).unwrap(); + assert_eq!(status, IntegrityStatus::NotInstalled); + } + + #[test] + fn test_verify_not_installed_no_pre_tool_use() { + let dir = make_settings(r#"{ "hooks": {} }"#); + let path = dir.path().join("settings.json"); + let status = verify_hook_in_settings(&path).unwrap(); + assert_eq!(status, IntegrityStatus::NotInstalled); + } + + #[test] + fn test_verify_not_installed_unrelated_command() { + let dir = make_settings( + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "/usr/local/bin/some-other-hook.sh" }] + } + ] + } +}"#, + ); + let path = dir.path().join("settings.json"); + let status = verify_hook_in_settings(&path).unwrap(); + assert_eq!(status, IntegrityStatus::NotInstalled); + } } From 4f1a3175f118095aa37407da7e5b6fdbd77ea1d7 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Fri, 27 Mar 2026 11:26:54 +0100 Subject: [PATCH 5/5] fix(scripts): validate-docs.sh checks README.md only for command docs 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 Signed-off-by: Florian BRUNIAUX --- scripts/validate-docs.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/validate-docs.sh b/scripts/validate-docs.sh index a8985557..7915ead3 100755 --- a/scripts/validate-docs.sh +++ b/scripts/validate-docs.sh @@ -26,7 +26,7 @@ PYTHON_GO_CMDS=("ruff" "pytest" "pip" "go" "golangci") echo "🐍 Checking Python/Go commands documentation..." for cmd in "${PYTHON_GO_CMDS[@]}"; do - for file in README.md CLAUDE.md; do + for file in README.md; do if [ ! -f "$file" ]; then echo "⚠️ $file not found, skipping" continue @@ -37,7 +37,7 @@ for cmd in "${PYTHON_GO_CMDS[@]}"; do fi done done -echo "✅ Python/Go commands: documented in README.md and CLAUDE.md" +echo "✅ Python/Go commands: documented in README.md" # 4. Hooks cohérents avec doc HOOK_FILE=".claude/hooks/rtk-rewrite.sh"