Skip to content

Commit 0649e97

Browse files
committed
fix: hook security + stderr redirects + version bump (#807)
* fix(hook): respect Claude Code deny/ask permission rules on rewrite * fix(permissions): check deny rules before rewrite + flush stdout * fix(permissions): support *:* and leading/middle wildcards * fix: strip trailing stderr redirects before rewrite matching (#530) * chore: bump version to 0.33.0 Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu>
1 parent 2cc01f4 commit 0649e97

12 files changed

Lines changed: 691 additions & 73 deletions

File tree

.claude/hooks/rtk-rewrite.sh

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
#!/bin/bash
2+
# rtk-hook-version: 3
23
# RTK auto-rewrite hook for Claude Code PreToolUse:Bash
34
# Transparently rewrites raw commands to their RTK equivalents.
45
# Uses `rtk rewrite` as single source of truth — no duplicate mapping logic here.
56
#
67
# To add support for new commands, update src/discover/registry.rs (PATTERNS + RULES).
8+
#
9+
# Exit code protocol for `rtk rewrite`:
10+
# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow
11+
# 1 No RTK equivalent → pass through unchanged
12+
# 2 Deny rule matched → pass through (Claude Code native deny handles it)
13+
# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user
714

815
# --- Audit logging (opt-in via RTK_HOOK_AUDIT=1) ---
916
_rtk_audit_log() {
@@ -37,34 +44,64 @@ case "$CMD" in
3744
*'<<'*) _rtk_audit_log "skip:heredoc" "$CMD"; exit 0 ;;
3845
esac
3946

40-
# Rewrite via rtk — single source of truth for all command mappings.
41-
# Exit 1 = no RTK equivalent, pass through unchanged.
42-
# Exit 0 = rewritten command (or already RTK, identical output).
43-
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || {
44-
_rtk_audit_log "skip:no_match" "$CMD"
45-
exit 0
46-
}
47+
# Rewrite via rtk — single source of truth for all command mappings and permission checks.
48+
# Use "|| EXIT_CODE=$?" to capture non-zero exit codes without triggering set -e.
49+
EXIT_CODE=0
50+
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || EXIT_CODE=$?
4751

48-
# If output is identical, command was already using RTK — nothing to do.
49-
if [ "$CMD" = "$REWRITTEN" ]; then
50-
_rtk_audit_log "skip:already_rtk" "$CMD"
51-
exit 0
52-
fi
52+
case $EXIT_CODE in
53+
0)
54+
# Rewrite found, no permission rules matched — safe to auto-allow.
55+
if [ "$CMD" = "$REWRITTEN" ]; then
56+
_rtk_audit_log "skip:already_rtk" "$CMD"
57+
exit 0
58+
fi
59+
;;
60+
1)
61+
# No RTK equivalent — pass through unchanged.
62+
_rtk_audit_log "skip:no_match" "$CMD"
63+
exit 0
64+
;;
65+
2)
66+
# Deny rule matched — let Claude Code's native deny rule handle it.
67+
_rtk_audit_log "skip:deny_rule" "$CMD"
68+
exit 0
69+
;;
70+
3)
71+
# Ask rule matched — rewrite the command but do NOT auto-allow so that
72+
# Claude Code prompts the user for confirmation.
73+
;;
74+
*)
75+
exit 0
76+
;;
77+
esac
5378

5479
_rtk_audit_log "rewrite" "$CMD" "$REWRITTEN"
5580

5681
# Build the updated tool_input with all original fields preserved, only command changed.
5782
ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
5883
UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd')
5984

60-
# Output the rewrite instruction in Claude Code hook format.
61-
jq -n \
62-
--argjson updated "$UPDATED_INPUT" \
63-
'{
64-
"hookSpecificOutput": {
65-
"hookEventName": "PreToolUse",
66-
"permissionDecision": "allow",
67-
"permissionDecisionReason": "RTK auto-rewrite",
68-
"updatedInput": $updated
69-
}
70-
}'
85+
if [ "$EXIT_CODE" -eq 3 ]; then
86+
# Ask: rewrite the command, omit permissionDecision so Claude Code prompts.
87+
jq -n \
88+
--argjson updated "$UPDATED_INPUT" \
89+
'{
90+
"hookSpecificOutput": {
91+
"hookEventName": "PreToolUse",
92+
"updatedInput": $updated
93+
}
94+
}'
95+
else
96+
# Allow: output the rewrite instruction in Claude Code hook format.
97+
jq -n \
98+
--argjson updated "$UPDATED_INPUT" \
99+
'{
100+
"hookSpecificOutput": {
101+
"hookEventName": "PreToolUse",
102+
"permissionDecision": "allow",
103+
"permissionDecisionReason": "RTK auto-rewrite",
104+
"updatedInput": $updated
105+
}
106+
}'
107+
fi

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "0.33.0-rc.54"
2+
".": "0.33.0"
33
}

ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,14 +290,15 @@ SYSTEM init.rs init N/A ✓
290290
gain.rs gain N/A ✓
291291
config.rs (internal) N/A ✓
292292
rewrite_cmd.rs rewrite N/A ✓
293+
permissions.rs CC permission checks N/A ✓
293294
294295
SHARED utils.rs Helpers N/A ✓
295296
filter.rs Language filters N/A ✓
296297
tracking.rs Token tracking N/A ✓
297298
tee.rs Full output recovery N/A ✓
298299
```
299300

300-
**Total: 67 modules** (45 command modules + 22 infrastructure modules)
301+
**Total: 71 modules** (49 command modules + 22 infrastructure modules)
301302

302303
### Module Count Breakdown
303304

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4242

4343
### Bug Fixes
4444

45+
* **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
46+
* **git:** replace symbol prefixes (`* branch`, `+ Staged:`, `~ Modified:`, `? Untracked:`) with plain lowercase labels (`branch:`, `staged:`, `modified:`, `untracked:`) in git status output
4547
* **ruby:** use `rails test` instead of `rake test` when positional file args are passed — `rake test` ignores positional files and only supports `TEST=path`
4648

4749
### Features

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rtk"
3-
version = "0.33.0-rc.54"
3+
version = "0.33.0"
44
edition = "2021"
55
authors = ["Patrick Szymkowiak"]
66
description = "Rust Token Killer - High-performance CLI proxy to minimize LLM token consumption"

hooks/rtk-rewrite.sh

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
#!/usr/bin/env bash
2-
# rtk-hook-version: 2
2+
# rtk-hook-version: 3
33
# RTK Claude Code hook — rewrites commands to use rtk for token savings.
44
# Requires: rtk >= 0.23.0, jq
55
#
66
# This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`,
77
# which is the single source of truth (src/discover/registry.rs).
88
# To add or change rewrite rules, edit the Rust registry — not this file.
9+
#
10+
# Exit code protocol for `rtk rewrite`:
11+
# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow
12+
# 1 No RTK equivalent → pass through unchanged
13+
# 2 Deny rule matched → pass through (Claude Code native deny handles it)
14+
# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user
915

1016
if ! command -v jq &>/dev/null; then
1117
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
3743
exit 0
3844
fi
3945

40-
# Delegate all rewrite logic to the Rust binary.
41-
# rtk rewrite exits 1 when there's no rewrite — hook passes through silently.
42-
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || exit 0
46+
# Delegate all rewrite + permission logic to the Rust binary.
47+
REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null)
48+
EXIT_CODE=$?
4349

44-
# No change — nothing to do.
45-
if [ "$CMD" = "$REWRITTEN" ]; then
46-
exit 0
47-
fi
50+
case $EXIT_CODE in
51+
0)
52+
# Rewrite found, no permission rules matched — safe to auto-allow.
53+
# If the output is identical, the command was already using RTK.
54+
[ "$CMD" = "$REWRITTEN" ] && exit 0
55+
;;
56+
1)
57+
# No RTK equivalent — pass through unchanged.
58+
exit 0
59+
;;
60+
2)
61+
# Deny rule matched — let Claude Code's native deny rule handle it.
62+
exit 0
63+
;;
64+
3)
65+
# Ask rule matched — rewrite the command but do NOT auto-allow so that
66+
# Claude Code prompts the user for confirmation.
67+
;;
68+
*)
69+
exit 0
70+
;;
71+
esac
4872

4973
ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
5074
UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd')
5175

52-
jq -n \
53-
--argjson updated "$UPDATED_INPUT" \
54-
'{
55-
"hookSpecificOutput": {
56-
"hookEventName": "PreToolUse",
57-
"permissionDecision": "allow",
58-
"permissionDecisionReason": "RTK auto-rewrite",
59-
"updatedInput": $updated
60-
}
61-
}'
76+
if [ "$EXIT_CODE" -eq 3 ]; then
77+
# Ask: rewrite the command, omit permissionDecision so Claude Code prompts.
78+
jq -n \
79+
--argjson updated "$UPDATED_INPUT" \
80+
'{
81+
"hookSpecificOutput": {
82+
"hookEventName": "PreToolUse",
83+
"updatedInput": $updated
84+
}
85+
}'
86+
else
87+
# Allow: rewrite the command and auto-allow.
88+
jq -n \
89+
--argjson updated "$UPDATED_INPUT" \
90+
'{
91+
"hookSpecificOutput": {
92+
"hookEventName": "PreToolUse",
93+
"permissionDecision": "allow",
94+
"permissionDecisionReason": "RTK auto-rewrite",
95+
"updatedInput": $updated
96+
}
97+
}'
98+
fi

src/discover/registry.rs

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,34 @@ pub fn strip_disabled_prefix(cmd: &str) -> &str {
327327
trimmed[prefix_len..].trim_start()
328328
}
329329

330-
/// Rewrite a raw command to its RTK equivalent.
331-
///
332-
/// Returns `Some(rewritten)` if the command has an RTK equivalent or is already RTK.
330+
lazy_static! {
331+
// Match trailing shell redirections:
332+
// Alt 1: N>&M or N>&- (fd redirect/close): 2>&1, 1>&2, 2>&-
333+
// Alt 2: &>file or &>>file (bash redirect both): &>/dev/null
334+
// Alt 3: N>file or N>>file (fd to file): 2>/dev/null, >/tmp/out, 1>>log
335+
// Note: [^(\\s] excludes process substitutions like >(tee) from false-positive matching
336+
static ref TRAILING_REDIRECT: Regex =
337+
Regex::new(r"\s+(?:[0-9]?>&[0-9-]|&>>?\S+|[0-9]?>>?\s*[^(\s]\S*)\s*$").unwrap();
338+
}
339+
340+
/// Strip trailing stderr/stdout redirects from a command segment (#530).
341+
/// Returns (command_without_redirects, redirect_suffix).
342+
fn strip_trailing_redirects(cmd: &str) -> (&str, &str) {
343+
if let Some(m) = TRAILING_REDIRECT.find(cmd) {
344+
// Verify redirect is not inside quotes (single-pass count)
345+
let before = &cmd[..m.start()];
346+
let (sq, dq) = before.chars().fold((0u32, 0u32), |(s, d), c| match c {
347+
'\'' => (s + 1, d),
348+
'"' => (s, d + 1),
349+
_ => (s, d),
350+
});
351+
if sq % 2 == 0 && dq % 2 == 0 {
352+
return (&cmd[..m.start()], &cmd[m.start()..]);
353+
}
354+
}
355+
(cmd, "")
356+
}
357+
333358
/// Returns `None` if the command is unsupported or ignored (hook should pass through).
334359
///
335360
/// Handles compound commands (`&&`, `||`, `;`) by rewriting each segment independently.
@@ -565,30 +590,34 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
565590
return None;
566591
}
567592

593+
// Strip trailing stderr/stdout redirects before matching (#530)
594+
// e.g. "git status 2>&1" → match "git status", re-append " 2>&1"
595+
let (cmd_part, redirect_suffix) = strip_trailing_redirects(trimmed);
596+
568597
// Already RTK — pass through unchanged
569-
if trimmed.starts_with("rtk ") || trimmed == "rtk" {
598+
if cmd_part.starts_with("rtk ") || cmd_part == "rtk" {
570599
return Some(trimmed.to_string());
571600
}
572601

573602
// Special case: `head -N file` / `head --lines=N file` → `rtk read file --max-lines N`
574603
// Must intercept before generic prefix replacement, which would produce `rtk read -20 file`.
575604
// Only intercept when head has a flag (-N, --lines=N, -c, etc.); plain `head file` falls
576605
// through to the generic rewrite below and produces `rtk read file` as expected.
577-
if trimmed.starts_with("head -") {
578-
return rewrite_head_numeric(trimmed);
606+
if cmd_part.starts_with("head -") {
607+
return rewrite_head_numeric(cmd_part).map(|r| format!("{}{}", r, redirect_suffix));
579608
}
580609

581610
// tail has several forms that are not compatible with generic prefix replacement.
582611
// Only rewrite recognized numeric line forms; otherwise skip rewrite.
583-
if trimmed.starts_with("tail ") {
584-
return rewrite_tail_lines(trimmed);
612+
if cmd_part.starts_with("tail ") {
613+
return rewrite_tail_lines(cmd_part).map(|r| format!("{}{}", r, redirect_suffix));
585614
}
586615

587616
// Use classify_command for correct ignore/prefix handling
588-
let rtk_equivalent = match classify_command(trimmed) {
617+
let rtk_equivalent = match classify_command(cmd_part) {
589618
Classification::Supported { rtk_equivalent, .. } => {
590619
// Check if the base command is excluded from rewriting (#243)
591-
let base = trimmed.split_whitespace().next().unwrap_or("");
620+
let base = cmd_part.split_whitespace().next().unwrap_or("");
592621
if excluded.iter().any(|e| e == base) {
593622
return None;
594623
}
@@ -601,13 +630,13 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
601630
let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?;
602631

603632
// Extract env prefix (sudo, env VAR=val, etc.)
604-
let stripped_cow = ENV_PREFIX.replace(trimmed, "");
605-
let env_prefix_len = trimmed.len() - stripped_cow.len();
606-
let env_prefix = &trimmed[..env_prefix_len];
633+
let stripped_cow = ENV_PREFIX.replace(cmd_part, "");
634+
let env_prefix_len = cmd_part.len() - stripped_cow.len();
635+
let env_prefix = &cmd_part[..env_prefix_len];
607636
let cmd_clean = stripped_cow.trim();
608637

609638
// #345: RTK_DISABLED=1 in env prefix → skip rewrite entirely
610-
if has_rtk_disabled_prefix(trimmed) {
639+
if has_rtk_disabled_prefix(cmd_part) {
611640
return None;
612641
}
613642

@@ -627,9 +656,9 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
627656
for &prefix in rule.rewrite_prefixes {
628657
if let Some(rest) = strip_word_prefix(cmd_clean, prefix) {
629658
let rewritten = if rest.is_empty() {
630-
format!("{}{}", env_prefix, rule.rtk_cmd)
659+
format!("{}{}{}", env_prefix, rule.rtk_cmd, redirect_suffix)
631660
} else {
632-
format!("{}{} {}", env_prefix, rule.rtk_cmd, rest)
661+
format!("{}{} {}{}", env_prefix, rule.rtk_cmd, rest, redirect_suffix)
633662
};
634663
return Some(rewritten);
635664
}
@@ -1285,6 +1314,35 @@ mod tests {
12851314
);
12861315
}
12871316

1317+
#[test]
1318+
fn test_rewrite_redirect_double() {
1319+
// Double redirect: only last one stripped, but full command rewrites correctly
1320+
assert_eq!(
1321+
rewrite_command("git status 2>&1 >/dev/null", &[]),
1322+
Some("rtk git status 2>&1 >/dev/null".into())
1323+
);
1324+
}
1325+
1326+
#[test]
1327+
fn test_rewrite_redirect_fd_close() {
1328+
// 2>&- (close stderr fd)
1329+
assert_eq!(
1330+
rewrite_command("git status 2>&-", &[]),
1331+
Some("rtk git status 2>&-".into())
1332+
);
1333+
}
1334+
1335+
#[test]
1336+
fn test_rewrite_redirect_quotes_not_stripped() {
1337+
// Redirect-like chars inside quotes should NOT be stripped
1338+
// Known limitation: apostrophes cause conservative no-strip (safe fallback)
1339+
let result = rewrite_command("git commit -m \"it's fixed\" 2>&1", &[]);
1340+
assert!(
1341+
result.is_some(),
1342+
"Should still rewrite even with apostrophe"
1343+
);
1344+
}
1345+
12881346
#[test]
12891347
fn test_rewrite_background_amp_non_regression() {
12901348
// background `&` must still work after redirect fix

src/hook_check.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::path::PathBuf;
22

3-
const CURRENT_HOOK_VERSION: u8 = 2;
3+
const CURRENT_HOOK_VERSION: u8 = 3;
44
const WARN_INTERVAL_SECS: u64 = 24 * 3600;
55

66
/// Hook status for diagnostics and `rtk gain`.

0 commit comments

Comments
 (0)