diff --git a/.claude/hooks/rtk-rewrite.sh b/.claude/hooks/rtk-rewrite.sh index e1f8d1e5..6e7524d8 100755 --- a/.claude/hooks/rtk-rewrite.sh +++ b/.claude/hooks/rtk-rewrite.sh @@ -1,9 +1,16 @@ #!/bin/bash +# rtk-hook-version: 3 # RTK auto-rewrite hook for Claude Code PreToolUse:Bash # Transparently rewrites raw commands to their RTK equivalents. # Uses `rtk rewrite` as single source of truth — no duplicate mapping logic here. # # To add support for new commands, update src/discover/registry.rs (PATTERNS + RULES). +# +# Exit code protocol for `rtk rewrite`: +# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow +# 1 No RTK equivalent → pass through unchanged +# 2 Deny rule matched → pass through (Claude Code native deny handles it) +# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user # --- Audit logging (opt-in via RTK_HOOK_AUDIT=1) --- _rtk_audit_log() { @@ -37,19 +44,37 @@ case "$CMD" in *'<<'*) _rtk_audit_log "skip:heredoc" "$CMD"; exit 0 ;; esac -# Rewrite via rtk — single source of truth for all command mappings. -# Exit 1 = no RTK equivalent, pass through unchanged. -# Exit 0 = rewritten command (or already RTK, identical output). -REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { - _rtk_audit_log "skip:no_match" "$CMD" - exit 0 -} +# Rewrite via rtk — single source of truth for all command mappings and permission checks. +# Use "|| EXIT_CODE=$?" to capture non-zero exit codes without triggering set -e. +EXIT_CODE=0 +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || EXIT_CODE=$? -# If output is identical, command was already using RTK — nothing to do. -if [ "$CMD" = "$REWRITTEN" ]; then - _rtk_audit_log "skip:already_rtk" "$CMD" - exit 0 -fi +case $EXIT_CODE in + 0) + # Rewrite found, no permission rules matched — safe to auto-allow. + if [ "$CMD" = "$REWRITTEN" ]; then + _rtk_audit_log "skip:already_rtk" "$CMD" + exit 0 + fi + ;; + 1) + # No RTK equivalent — pass through unchanged. + _rtk_audit_log "skip:no_match" "$CMD" + exit 0 + ;; + 2) + # Deny rule matched — let Claude Code's native deny rule handle it. + _rtk_audit_log "skip:deny_rule" "$CMD" + exit 0 + ;; + 3) + # Ask rule matched — rewrite the command but do NOT auto-allow so that + # Claude Code prompts the user for confirmation. + ;; + *) + exit 0 + ;; +esac _rtk_audit_log "rewrite" "$CMD" "$REWRITTEN" @@ -57,14 +82,26 @@ _rtk_audit_log "rewrite" "$CMD" "$REWRITTEN" ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input') UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd') -# Output the rewrite instruction in Claude Code hook format. -jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": $updated - } - }' +if [ "$EXIT_CODE" -eq 3 ]; then + # Ask: rewrite the command, omit permissionDecision so Claude Code prompts. + jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": $updated + } + }' +else + # Allow: output the rewrite instruction in Claude Code hook format. + jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": $updated + } + }' +fi diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 1d29a855..393f9e50 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -68,7 +68,7 @@ jobs: fi fi VERSION="${MAJOR}.${MINOR}.${PATCH}" - TAG="v${VERSION}-rc.${{ github.run_number }}" + TAG="dev-${VERSION}-rc.${{ github.run_number }}" echo "Next version: $VERSION (from $LATEST_VERSION)" echo "Pre-release tag: $TAG" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 289c10c4..53ecb865 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.33.0-rc.54" + ".": "0.33.0" } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0ae617e1..94f44905 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -290,6 +290,7 @@ SYSTEM init.rs init N/A ✓ gain.rs gain N/A ✓ config.rs (internal) N/A ✓ rewrite_cmd.rs rewrite N/A ✓ + permissions.rs CC permission checks N/A ✓ SHARED utils.rs Helpers N/A ✓ filter.rs Language filters N/A ✓ @@ -297,7 +298,7 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 67 modules** (45 command modules + 22 infrastructure modules) +**Total: 71 modules** (49 command modules + 22 infrastructure modules) ### Module Count Breakdown diff --git a/CHANGELOG.md b/CHANGELOG.md index de59aa59..438b2859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes +* **hook:** respect Claude Code deny/ask permission rules on rewrite — hook now checks settings.json before rewriting commands, preventing bypass of user-configured deny/ask permissions +* **git:** replace symbol prefixes (`* branch`, `+ Staged:`, `~ Modified:`, `? Untracked:`) with plain lowercase labels (`branch:`, `staged:`, `modified:`, `untracked:`) in git status output * **ruby:** use `rails test` instead of `rake test` when positional file args are passed — `rake test` ignores positional files and only supports `TEST=path` ### Features diff --git a/Cargo.lock b/Cargo.lock index 4f888be9..c95377da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,7 +892,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.33.0-rc.54" +version = "0.33.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 9a054a5a..a811ba62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtk" -version = "0.33.0-rc.54" +version = "0.33.0" edition = "2021" authors = ["Patrick Szymkowiak"] description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption" diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index c9c00f47..f7a42b5d 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -1,11 +1,17 @@ #!/usr/bin/env bash -# rtk-hook-version: 2 +# rtk-hook-version: 3 # RTK Claude Code hook — rewrites commands to use rtk for token savings. # Requires: rtk >= 0.23.0, jq # # This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`, # which is the single source of truth (src/discover/registry.rs). # To add or change rewrite rules, edit the Rust registry — not this file. +# +# Exit code protocol for `rtk rewrite`: +# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow +# 1 No RTK equivalent → pass through unchanged +# 2 Deny rule matched → pass through (Claude Code native deny handles it) +# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user if ! command -v jq &>/dev/null; then echo "[rtk] WARNING: jq is not installed. Hook cannot rewrite commands. Install jq: https://jqlang.github.io/jq/download/" >&2 @@ -37,25 +43,56 @@ if [ -z "$CMD" ]; then exit 0 fi -# Delegate all rewrite logic to the Rust binary. -# rtk rewrite exits 1 when there's no rewrite — hook passes through silently. -REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || exit 0 +# Delegate all rewrite + permission logic to the Rust binary. +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) +EXIT_CODE=$? -# No change — nothing to do. -if [ "$CMD" = "$REWRITTEN" ]; then - exit 0 -fi +case $EXIT_CODE in + 0) + # Rewrite found, no permission rules matched — safe to auto-allow. + # If the output is identical, the command was already using RTK. + [ "$CMD" = "$REWRITTEN" ] && exit 0 + ;; + 1) + # No RTK equivalent — pass through unchanged. + exit 0 + ;; + 2) + # Deny rule matched — let Claude Code's native deny rule handle it. + exit 0 + ;; + 3) + # Ask rule matched — rewrite the command but do NOT auto-allow so that + # Claude Code prompts the user for confirmation. + ;; + *) + exit 0 + ;; +esac ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input') UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd') -jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": $updated - } - }' +if [ "$EXIT_CODE" -eq 3 ]; then + # Ask: rewrite the command, omit permissionDecision so Claude Code prompts. + jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": $updated + } + }' +else + # Allow: rewrite the command and auto-allow. + jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": $updated + } + }' +fi diff --git a/src/discover/registry.rs b/src/discover/registry.rs index d04a112a..fafdaa8b 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -327,9 +327,34 @@ pub fn strip_disabled_prefix(cmd: &str) -> &str { trimmed[prefix_len..].trim_start() } -/// Rewrite a raw command to its RTK equivalent. -/// -/// Returns `Some(rewritten)` if the command has an RTK equivalent or is already RTK. +lazy_static! { + // Match trailing shell redirections: + // Alt 1: N>&M or N>&- (fd redirect/close): 2>&1, 1>&2, 2>&- + // Alt 2: &>file or &>>file (bash redirect both): &>/dev/null + // Alt 3: N>file or N>>file (fd to file): 2>/dev/null, >/tmp/out, 1>>log + // Note: [^(\\s] excludes process substitutions like >(tee) from false-positive matching + static ref TRAILING_REDIRECT: Regex = + Regex::new(r"\s+(?:[0-9]?>&[0-9-]|&>>?\S+|[0-9]?>>?\s*[^(\s]\S*)\s*$").unwrap(); +} + +/// Strip trailing stderr/stdout redirects from a command segment (#530). +/// Returns (command_without_redirects, redirect_suffix). +fn strip_trailing_redirects(cmd: &str) -> (&str, &str) { + if let Some(m) = TRAILING_REDIRECT.find(cmd) { + // Verify redirect is not inside quotes (single-pass count) + let before = &cmd[..m.start()]; + let (sq, dq) = before.chars().fold((0u32, 0u32), |(s, d), c| match c { + '\'' => (s + 1, d), + '"' => (s, d + 1), + _ => (s, d), + }); + if sq % 2 == 0 && dq % 2 == 0 { + return (&cmd[..m.start()], &cmd[m.start()..]); + } + } + (cmd, "") +} + /// Returns `None` if the command is unsupported or ignored (hook should pass through). /// /// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently. @@ -565,8 +590,12 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option { return None; } + // Strip trailing stderr/stdout redirects before matching (#530) + // e.g. "git status 2>&1" → match "git status", re-append " 2>&1" + let (cmd_part, redirect_suffix) = strip_trailing_redirects(trimmed); + // Already RTK — pass through unchanged - if trimmed.starts_with("rtk ") || trimmed == "rtk" { + if cmd_part.starts_with("rtk ") || cmd_part == "rtk" { return Some(trimmed.to_string()); } @@ -574,21 +603,21 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option { // Must intercept before generic prefix replacement, which would produce `rtk read -20 file`. // Only intercept when head has a flag (-N, --lines=N, -c, etc.); plain `head file` falls // through to the generic rewrite below and produces `rtk read file` as expected. - if trimmed.starts_with("head -") { - return rewrite_head_numeric(trimmed); + if cmd_part.starts_with("head -") { + return rewrite_head_numeric(cmd_part).map(|r| format!("{}{}", r, redirect_suffix)); } // tail has several forms that are not compatible with generic prefix replacement. // Only rewrite recognized numeric line forms; otherwise skip rewrite. - if trimmed.starts_with("tail ") { - return rewrite_tail_lines(trimmed); + if cmd_part.starts_with("tail ") { + return rewrite_tail_lines(cmd_part).map(|r| format!("{}{}", r, redirect_suffix)); } // Use classify_command for correct ignore/prefix handling - let rtk_equivalent = match classify_command(trimmed) { + let rtk_equivalent = match classify_command(cmd_part) { Classification::Supported { rtk_equivalent, .. } => { // Check if the base command is excluded from rewriting (#243) - let base = trimmed.split_whitespace().next().unwrap_or(""); + let base = cmd_part.split_whitespace().next().unwrap_or(""); if excluded.iter().any(|e| e == base) { return None; } @@ -601,13 +630,13 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option { let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?; // Extract env prefix (sudo, env VAR=val, etc.) - let stripped_cow = ENV_PREFIX.replace(trimmed, ""); - let env_prefix_len = trimmed.len() - stripped_cow.len(); - let env_prefix = &trimmed[..env_prefix_len]; + let stripped_cow = ENV_PREFIX.replace(cmd_part, ""); + let env_prefix_len = cmd_part.len() - stripped_cow.len(); + let env_prefix = &cmd_part[..env_prefix_len]; let cmd_clean = stripped_cow.trim(); // #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely - if has_rtk_disabled_prefix(trimmed) { + if has_rtk_disabled_prefix(cmd_part) { return None; } @@ -627,9 +656,9 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option { for &prefix in rule.rewrite_prefixes { if let Some(rest) = strip_word_prefix(cmd_clean, prefix) { let rewritten = if rest.is_empty() { - format!("{}{}", env_prefix, rule.rtk_cmd) + format!("{}{}{}", env_prefix, rule.rtk_cmd, redirect_suffix) } else { - format!("{}{} {}", env_prefix, rule.rtk_cmd, rest) + format!("{}{} {}{}", env_prefix, rule.rtk_cmd, rest, redirect_suffix) }; return Some(rewritten); } @@ -1285,6 +1314,35 @@ mod tests { ); } + #[test] + fn test_rewrite_redirect_double() { + // Double redirect: only last one stripped, but full command rewrites correctly + assert_eq!( + rewrite_command("git status 2>&1 >/dev/null", &[]), + Some("rtk git status 2>&1 >/dev/null".into()) + ); + } + + #[test] + fn test_rewrite_redirect_fd_close() { + // 2>&- (close stderr fd) + assert_eq!( + rewrite_command("git status 2>&-", &[]), + Some("rtk git status 2>&-".into()) + ); + } + + #[test] + fn test_rewrite_redirect_quotes_not_stripped() { + // Redirect-like chars inside quotes should NOT be stripped + // Known limitation: apostrophes cause conservative no-strip (safe fallback) + let result = rewrite_command("git commit -m \"it's fixed\" 2>&1", &[]); + assert!( + result.is_some(), + "Should still rewrite even with apostrophe" + ); + } + #[test] fn test_rewrite_background_amp_non_regression() { // background `&` must still work after redirect fix diff --git a/src/hook_check.rs b/src/hook_check.rs index 2716ec15..50a6537a 100644 --- a/src/hook_check.rs +++ b/src/hook_check.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -const CURRENT_HOOK_VERSION: u8 = 2; +const CURRENT_HOOK_VERSION: u8 = 3; const WARN_INTERVAL_SECS: u64 = 24 * 3600; /// Hook status for diagnostics and `rtk gain`. diff --git a/src/main.rs b/src/main.rs index 0ff5124c..654a2676 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,7 @@ mod mypy_cmd; mod next_cmd; mod npm_cmd; mod parser; +mod permissions; mod pip_cmd; mod playwright_cmd; mod pnpm_cmd; diff --git a/src/permissions.rs b/src/permissions.rs new file mode 100644 index 00000000..52fad6a4 --- /dev/null +++ b/src/permissions.rs @@ -0,0 +1,461 @@ +use serde_json::Value; +use std::path::PathBuf; + +/// Verdict from checking a command against Claude Code's permission rules. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum PermissionVerdict { + /// No deny/ask rules matched — safe to auto-allow. + Allow, + /// A deny rule matched — pass through to Claude Code's native deny handling. + Deny, + /// An ask rule matched — rewrite the command but let Claude Code prompt the user. + Ask, +} + +/// Check `cmd` against Claude Code's deny/ask permission rules. +/// +/// Returns `Allow` when no rules match (preserves existing behavior), +/// `Deny` when a deny rule matches, or `Ask` when an ask rule matches. +/// Deny takes priority over Ask if both match the same command. +pub fn check_command(cmd: &str) -> PermissionVerdict { + let (deny_rules, ask_rules) = load_deny_ask_rules(); + check_command_with_rules(cmd, &deny_rules, &ask_rules) +} + +/// Internal implementation allowing tests to inject rules without file I/O. +pub(crate) fn check_command_with_rules( + cmd: &str, + deny_rules: &[String], + ask_rules: &[String], +) -> PermissionVerdict { + let segments = split_compound_command(cmd); + let mut any_ask = false; + + for segment in &segments { + let segment = segment.trim(); + if segment.is_empty() { + continue; + } + + for pattern in deny_rules { + if command_matches_pattern(segment, pattern) { + return PermissionVerdict::Deny; + } + } + + if !any_ask { + for pattern in ask_rules { + if command_matches_pattern(segment, pattern) { + any_ask = true; + break; + } + } + } + } + + if any_ask { + PermissionVerdict::Ask + } else { + PermissionVerdict::Allow + } +} + +/// Load deny and ask Bash rules from all Claude Code settings files. +/// +/// Files read (in order, later files do not override earlier ones — all are merged): +/// 1. `$PROJECT_ROOT/.claude/settings.json` +/// 2. `$PROJECT_ROOT/.claude/settings.local.json` +/// 3. `~/.claude/settings.json` +/// 4. `~/.claude/settings.local.json` +/// +/// Missing files and malformed JSON are silently skipped. +fn load_deny_ask_rules() -> (Vec, Vec) { + let mut deny_rules = Vec::new(); + let mut ask_rules = Vec::new(); + + for path in get_settings_paths() { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(json) = serde_json::from_str::(&content) else { + continue; + }; + let Some(permissions) = json.get("permissions") else { + continue; + }; + + append_bash_rules(permissions.get("deny"), &mut deny_rules); + append_bash_rules(permissions.get("ask"), &mut ask_rules); + } + + (deny_rules, ask_rules) +} + +/// Extract Bash-scoped patterns from a JSON array and append them to `target`. +/// +/// Only rules with a `Bash(...)` prefix are kept. Non-Bash rules (e.g. `Read(...)`) are ignored. +fn append_bash_rules(rules_value: Option<&Value>, target: &mut Vec) { + let Some(arr) = rules_value.and_then(|v| v.as_array()) else { + return; + }; + for rule in arr { + if let Some(s) = rule.as_str() { + if s.starts_with("Bash(") { + target.push(extract_bash_pattern(s).to_string()); + } + } + } +} + +/// Return the ordered list of Claude Code settings file paths to check. +fn get_settings_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(root) = find_project_root() { + paths.push(root.join(".claude").join("settings.json")); + paths.push(root.join(".claude").join("settings.local.json")); + } + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".claude").join("settings.json")); + paths.push(home.join(".claude").join("settings.local.json")); + } + + paths +} + +/// Locate the project root by walking up from CWD looking for `.claude/`. +/// +/// Falls back to `git rev-parse --show-toplevel` if not found via directory walk. +fn find_project_root() -> Option { + // Fast path: walk up CWD looking for .claude/ — no subprocess needed. + let mut dir = std::env::current_dir().ok()?; + loop { + if dir.join(".claude").exists() { + return Some(dir); + } + if !dir.pop() { + break; + } + } + + // Fallback: git (spawns a subprocess, slower but handles monorepo layouts). + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .ok()?; + + if output.status.success() { + let path = String::from_utf8(output.stdout).ok()?; + return Some(PathBuf::from(path.trim())); + } + + None +} + +/// Extract the pattern string from inside `Bash(pattern)`. +/// +/// Returns the original string unchanged if it does not match the expected format. +pub(crate) fn extract_bash_pattern(rule: &str) -> &str { + if let Some(inner) = rule.strip_prefix("Bash(") { + if let Some(pattern) = inner.strip_suffix(')') { + return pattern; + } + } + rule +} + +/// Check if `cmd` matches a Claude Code permission pattern. +/// +/// Pattern forms: +/// - `*` → matches everything +/// - `prefix:*` or `prefix *` (trailing `*`, no other wildcards) → prefix match with word boundary +/// - `* suffix`, `pre * suf` → glob matching where `*` matches any sequence of characters +/// - `pattern` → exact match or prefix match (cmd must equal pattern or start with `{pattern} `) +pub(crate) fn command_matches_pattern(cmd: &str, pattern: &str) -> bool { + // 1. Global wildcard + if pattern == "*" { + return true; + } + + // 2. Trailing-only wildcard: fast path with word-boundary preservation + // Handles: "git push*", "git push *", "sudo:*" + if let Some(p) = pattern.strip_suffix('*') { + let prefix = p.trim_end_matches(':').trim_end(); + // Bug 2 fix: after stripping, if prefix is empty or just wildcards, match everything + if prefix.is_empty() || prefix == "*" { + return true; + } + // No other wildcards in prefix -> use word-boundary fast path + if !prefix.contains('*') { + return cmd == prefix || cmd.starts_with(&format!("{} ", prefix)); + } + // Prefix still contains '*' -> fall through to glob matching + } + + // 3. Complex wildcards (leading, middle, multiple): glob matching + if pattern.contains('*') { + return glob_matches(cmd, pattern); + } + + // 4. No wildcard: exact match or prefix with word boundary + cmd == pattern || cmd.starts_with(&format!("{} ", pattern)) +} + +/// Glob-style matching where `*` matches any character sequence (including empty). +/// +/// Colon syntax normalized: `sudo:*` treated as `sudo *` for word separation. +fn glob_matches(cmd: &str, pattern: &str) -> bool { + // Normalize colon-wildcard syntax: "sudo:*" -> "sudo *", "*:rm" -> "* rm" + let normalized = pattern.replace(":*", " *").replace("*:", "* "); + let parts: Vec<&str> = normalized.split('*').collect(); + + // All-stars pattern (e.g. "***") matches everything + if parts.iter().all(|p| p.is_empty()) { + return true; + } + + let mut search_from = 0; + + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + + if i == 0 { + // First segment: must be prefix (pattern doesn't start with *) + if !cmd.starts_with(part) { + return false; + } + search_from = part.len(); + } else if i == parts.len() - 1 { + // Last segment: must be suffix (pattern doesn't end with *) + if !cmd[search_from..].ends_with(*part) { + return false; + } + } else { + // Middle segment: find next occurrence + match cmd[search_from..].find(*part) { + Some(pos) => search_from += pos + part.len(), + None => return false, + } + } + } + + true +} + +/// Split a compound shell command into individual segments. +/// +/// Splits on `&&`, `||`, `|`, and `;`. Not a full shell parser — handles common cases. +fn split_compound_command(cmd: &str) -> Vec<&str> { + cmd.split("&&") + .flat_map(|s| s.split("||")) + .flat_map(|s| s.split(['|', ';'])) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_bash_pattern() { + assert_eq!( + extract_bash_pattern("Bash(git push --force)"), + "git push --force" + ); + assert_eq!(extract_bash_pattern("Bash(*)"), "*"); + assert_eq!(extract_bash_pattern("Bash(sudo:*)"), "sudo:*"); + assert_eq!(extract_bash_pattern("Read(**/.env*)"), "Read(**/.env*)"); // unchanged + } + + #[test] + fn test_exact_match() { + assert!(command_matches_pattern( + "git push --force", + "git push --force" + )); + } + + #[test] + fn test_wildcard_colon() { + assert!(command_matches_pattern("sudo rm -rf /", "sudo:*")); + } + + #[test] + fn test_no_match() { + assert!(!command_matches_pattern("git status", "git push --force")); + } + + #[test] + fn test_deny_precedence_over_ask() { + let deny = vec!["git push --force".to_string()]; + let ask = vec!["git push --force".to_string()]; + assert_eq!( + check_command_with_rules("git push --force", &deny, &ask), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_non_bash_rules_ignored() { + // Non-Bash rules (e.g. Read, Write) must not match Bash commands. + // In load_deny_ask_rules, only Bash( rules are kept — we verify that + // extract_bash_pattern returns the original string for non-Bash rules. + assert_eq!(extract_bash_pattern("Read(**/.env*)"), "Read(**/.env*)"); + + // With empty rule sets (what you get after filtering out non-Bash rules), + // verdict is always Allow. + assert_eq!( + check_command_with_rules("cat .env", &[], &[]), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_empty_permissions() { + assert_eq!( + check_command_with_rules("git push --force", &[], &[]), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_prefix_match() { + assert!(command_matches_pattern( + "git push --force origin main", + "git push --force" + )); + } + + #[test] + fn test_wildcard_all() { + assert!(command_matches_pattern("anything at all", "*")); + assert!(command_matches_pattern("", "*")); + } + + #[test] + fn test_no_partial_word_match() { + // "git push --forceful" must NOT match pattern "git push --force". + assert!(!command_matches_pattern( + "git push --forceful", + "git push --force" + )); + } + + #[test] + fn test_compound_command_deny() { + let deny = vec!["git push --force".to_string()]; + assert_eq!( + check_command_with_rules("git status && git push --force", &deny, &[]), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_compound_command_ask() { + let ask = vec!["git push".to_string()]; + assert_eq!( + check_command_with_rules("git status && git push origin main", &[], &ask), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_compound_command_deny_overrides_ask() { + let deny = vec!["git push --force".to_string()]; + let ask = vec!["git status".to_string()]; + // deny in compound cmd takes priority even if ask also matches a segment + assert_eq!( + check_command_with_rules("git status && git push --force", &deny, &ask), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_ask_verdict() { + let ask = vec!["git push".to_string()]; + assert_eq!( + check_command_with_rules("git push origin main", &[], &ask), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_sudo_wildcard_no_false_positive() { + // "sudoedit" must NOT match "sudo:*" (word boundary respected). + assert!(!command_matches_pattern("sudoedit /etc/hosts", "sudo:*")); + } + + // Bug 2: *:* catch-all must match everything + #[test] + fn test_star_colon_star_matches_everything() { + assert!(command_matches_pattern("rm -rf /", "*:*")); + assert!(command_matches_pattern("git push --force", "*:*")); + assert!(command_matches_pattern("anything", "*:*")); + } + + // Bug 3: leading wildcard — positive + #[test] + fn test_leading_wildcard() { + assert!(command_matches_pattern("git push --force", "* --force")); + assert!(command_matches_pattern("npm run --force", "* --force")); + } + + // Bug 3: leading wildcard — negative (suffix anchoring) + #[test] + fn test_leading_wildcard_no_partial() { + assert!(!command_matches_pattern("git push --forceful", "* --force")); + assert!(!command_matches_pattern("git push", "* --force")); + } + + // Bug 3: middle wildcard — positive + #[test] + fn test_middle_wildcard() { + assert!(command_matches_pattern("git push main", "git * main")); + assert!(command_matches_pattern("git rebase main", "git * main")); + } + + // Bug 3: middle wildcard — negative + #[test] + fn test_middle_wildcard_no_match() { + assert!(!command_matches_pattern("git push develop", "git * main")); + } + + // Bug 3: multiple wildcards + #[test] + fn test_multiple_wildcards() { + assert!(command_matches_pattern( + "git push --force origin main", + "git * --force *" + )); + assert!(!command_matches_pattern( + "git pull origin main", + "git * --force *" + )); + } + + // Integration: deny with leading wildcard + #[test] + fn test_deny_with_leading_wildcard() { + let deny = vec!["* --force".to_string()]; + assert_eq!( + check_command_with_rules("git push --force", &deny, &[]), + PermissionVerdict::Deny + ); + assert_eq!( + check_command_with_rules("git push", &deny, &[]), + PermissionVerdict::Allow + ); + } + + // Integration: deny *:* blocks everything + #[test] + fn test_deny_star_colon_star() { + let deny = vec!["*:*".to_string()]; + assert_eq!( + check_command_with_rules("rm -rf /", &deny, &[]), + PermissionVerdict::Deny + ); + } +} diff --git a/src/rewrite_cmd.rs b/src/rewrite_cmd.rs index 754f51a9..c64997b4 100644 --- a/src/rewrite_cmd.rs +++ b/src/rewrite_cmd.rs @@ -1,26 +1,47 @@ use crate::discover::registry; +use crate::permissions::{check_command, PermissionVerdict}; +use std::io::Write; /// Run the `rtk rewrite` command. /// -/// Prints the RTK-rewritten command to stdout and exits 0. -/// Exits 1 (without output) if the command has no RTK equivalent. +/// Prints the RTK-rewritten command to stdout and exits with a code that tells +/// the caller how to handle permissions: /// -/// Used by shell hooks to rewrite commands transparently: -/// ```bash -/// REWRITTEN=$(rtk rewrite "$CMD") || exit 0 -/// [ "$CMD" = "$REWRITTEN" ] && exit 0 # already RTK, skip -/// ``` +/// | Exit | Stdout | Meaning | +/// |------|----------|--------------------------------------------------------------| +/// | 0 | rewritten| Rewrite allowed — hook may auto-allow the rewritten command. | +/// | 1 | (none) | No RTK equivalent — hook passes through unchanged. | +/// | 2 | (none) | Deny rule matched — hook defers to Claude Code native deny. | +/// | 3 | rewritten| Ask rule matched — hook rewrites but lets Claude Code prompt.| pub fn run(cmd: &str) -> anyhow::Result<()> { let excluded = crate::config::Config::load() .map(|c| c.hooks.exclude_commands) .unwrap_or_default(); + // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. + let verdict = check_command(cmd); + + if verdict == PermissionVerdict::Deny { + std::process::exit(2); + } + match registry::rewrite_command(cmd, &excluded) { - Some(rewritten) => { - print!("{}", rewritten); - Ok(()) - } + Some(rewritten) => match verdict { + PermissionVerdict::Allow => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + Ok(()) + } + PermissionVerdict::Ask => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + std::process::exit(3); + } + PermissionVerdict::Deny => unreachable!(), + }, None => { + // No RTK equivalent. Exit 1 = passthrough. + // Claude Code independently evaluates its own ask rules on the original cmd. std::process::exit(1); } }