diff --git a/hooks/claude/rtk-rewrite.sh b/hooks/claude/rtk-rewrite.sh index 08edac31f..9471b0302 100644 --- a/hooks/claude/rtk-rewrite.sh +++ b/hooks/claude/rtk-rewrite.sh @@ -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 # @@ -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. @@ -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 @@ -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. @@ -74,7 +86,7 @@ case $EXIT_CODE in # Claude Code prompts the user for confirmation. ;; *) - exit 0 + _rtk_allow_passthrough ;; esac diff --git a/hooks/claude/test-rtk-rewrite.sh b/hooks/claude/test-rtk-rewrite.sh index 702fe9299..7a0c8aa2b 100644 --- a/hooks/claude/test-rtk-rewrite.sh +++ b/hooks/claude/test-rtk-rewrite.sh @@ -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 @@ -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) ---"