Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions hooks/claude/rtk-rewrite.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# rtk-hook-version: 3
# rtk-hook-version: 4
# RTK Claude Code hook — rewrites commands to use rtk for token savings.
# Requires: rtk >= 0.23.0, jq
#
Expand All @@ -13,14 +13,22 @@
# 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

# Emit an explicit allow decision so Claude Code honours --dangerously-skip-permissions
# even when the hook has no rewrite to apply. Using printf avoids a jq dependency
# for the early-exit paths (missing tools, old version).
_rtk_allow_passthrough() {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n'
exit 0
}

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
exit 0
_rtk_allow_passthrough
fi

if ! command -v rtk &>/dev/null; then
echo "[rtk] WARNING: rtk is not installed or not in PATH. Hook cannot rewrite commands. Install: https://github.com/rtk-ai/rtk#installation" >&2
exit 0
_rtk_allow_passthrough
fi

# Version guard: rtk rewrite was added in 0.23.0.
Expand All @@ -37,7 +45,7 @@ if [ ! -f "$CACHE_FILE" ]; then
# Require >= 0.23.0
if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 23 ]; then
echo "[rtk] WARNING: rtk $RTK_VERSION is too old (need >= 0.23.0). Upgrade: cargo install rtk" >&2
exit 0
_rtk_allow_passthrough
fi
fi
mkdir -p "$CACHE_DIR" 2>/dev/null
Expand All @@ -59,11 +67,15 @@ 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
if [ "$CMD" = "$REWRITTEN" ]; then
_rtk_allow_passthrough
fi
;;
1)
# No RTK equivalent — pass through unchanged.
exit 0
# No RTK equivalent — pass through with explicit allow so that
# --dangerously-skip-permissions is respected (Claude Code treats a hook
# that exits 0 with no output differently from no hook being installed).
_rtk_allow_passthrough
;;
2)
# Deny rule matched — let Claude Code's native deny rule handle it.
Expand All @@ -74,7 +86,7 @@ case $EXIT_CODE in
# Claude Code prompts the user for confirmation.
;;
*)
exit 0
_rtk_allow_passthrough
;;
esac

Expand Down
52 changes: 45 additions & 7 deletions hooks/claude/test-rtk-rewrite.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,19 @@ test_rewrite() {
output=$(echo "$input_json" | bash "$HOOK" 2>/dev/null) || true

if [ -z "$expected_cmd" ]; then
# Expect no rewrite (hook exits 0 with no output)
if [ -z "$output" ]; then
printf " ${GREEN}PASS${RESET} %s ${DIM}→ (no rewrite)${RESET}\n" "$description"
# Expect passthrough: permissionDecision=allow, no updatedInput.command.
# The hook must emit an explicit allow so Claude Code respects
# --dangerously-skip-permissions even when no rewrite is applied.
local perm_decision updated_cmd
perm_decision=$(echo "$output" | jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null)
updated_cmd=$(echo "$output" | jq -r '.hookSpecificOutput.updatedInput.command // empty' 2>/dev/null)
if [ "$perm_decision" = "allow" ] && [ -z "$updated_cmd" ]; then
printf " ${GREEN}PASS${RESET} %s ${DIM}→ (allow, no rewrite)${RESET}\n" "$description"
PASS=$((PASS + 1))
else
local actual
actual=$(echo "$output" | jq -r '.hookSpecificOutput.updatedInput.command // empty')
printf " ${RED}FAIL${RESET} %s\n" "$description"
printf " expected: (no rewrite)\n"
printf " actual: %s\n" "$actual"
printf " expected: permissionDecision=allow, no rewrite\n"
printf " got: permissionDecision=%s, updated_cmd=%s\n" "$perm_decision" "$updated_cmd"
FAIL=$((FAIL + 1))
fi
else
Expand Down Expand Up @@ -327,6 +330,41 @@ test_rewrite "node (no pattern)" \

echo ""

# ---- SECTION 5b: --dangerously-skip-permissions bypass (issue #1033) ----
# When RTK has no rewrite to apply, the hook must output permissionDecision=allow
# so that --dangerously-skip-permissions is respected. A bare exit 0 with no
# output causes Claude Code to fall back to its default prompt behaviour.
echo "--- Passthrough must emit allow (issue #1033) ---"

test_passthrough_allows() {
local description="$1"
local input_cmd="$2"
TOTAL=$((TOTAL + 1))

local input_json output perm_decision
input_json=$(jq -n --arg cmd "$input_cmd" '{"tool_name":"Bash","tool_input":{"command":$cmd}}')
output=$(echo "$input_json" | bash "$HOOK" 2>/dev/null) || true
perm_decision=$(echo "$output" | jq -r '.hookSpecificOutput.permissionDecision // empty' 2>/dev/null)

if [ "$perm_decision" = "allow" ]; then
printf " ${GREEN}PASS${RESET} %s ${DIM}→ permissionDecision=allow${RESET}\n" "$description"
PASS=$((PASS + 1))
else
printf " ${RED}FAIL${RESET} %s\n" "$description"
printf " expected permissionDecision=allow, got: '%s'\n" "$perm_decision"
printf " full output: %s\n" "$output"
FAIL=$((FAIL + 1))
fi
}

test_passthrough_allows "which rtk (no rewrite)" "which rtk"
test_passthrough_allows "rtk --version (already rtk, no change)" "rtk --version"
test_passthrough_allows "claude mcp list (no rewrite)" "claude mcp list"
test_passthrough_allows "echo hello (no rewrite)" "echo hello"
test_passthrough_allows "cd /tmp (no rewrite)" "cd /tmp"

echo ""

# ---- SECTION 6: Audit logging ----
echo "--- Audit logging (RTK_HOOK_AUDIT=1) ---"

Expand Down
Loading