Skip to content
Closed
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
84 changes: 42 additions & 42 deletions hooks/rtk-rewrite.sh
Original file line number Diff line number Diff line change
@@ -1,61 +1,61 @@
#!/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
# v3: all JSON handling in Rust (no jq in primary path), pinned binary.
#
# 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.

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
# ── Resolve rtk binary (pinned path > PATH) ─────────────
# Priority: RTK_PATH env (testing/CI override) > pinned bin-path > PATH (with warning).
# RTK_PATH is intentional for CI and development; it requires the caller to
# explicitly set a specific env var, which is a narrower attack surface than PATH.
RTK_BIN="${RTK_PATH:-}"

if [ -z "$RTK_BIN" ]; then
BIN_PATH_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/rtk/bin-path"
if [ -f "$BIN_PATH_FILE" ]; then
RTK_BIN=$(cat "$BIN_PATH_FILE")
fi
fi

if [ -z "$RTK_BIN" ] || [ ! -x "$RTK_BIN" ]; then
RTK_BIN=$(command -v rtk 2>/dev/null || true)
if [ -n "$RTK_BIN" ]; then
echo "[rtk] WARNING: using PATH-resolved rtk ($RTK_BIN). Run \`rtk init -g\` to pin the binary path." >&2
fi
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
if [ -z "$RTK_BIN" ]; then
echo "[rtk] WARNING: rtk not found. Install: https://github.com/rtk-ai/rtk#installation" >&2
exit 0
fi

# Version guard: rtk rewrite was added in 0.23.0.
# Older binaries: warn once and exit cleanly (no silent failure).
RTK_VERSION=$(rtk --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
# ── Version guard ────────────────────────────────────────
RTK_VERSION=$("$RTK_BIN" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
if [ -n "$RTK_VERSION" ]; then
MAJOR=$(echo "$RTK_VERSION" | cut -d. -f1)
MINOR=$(echo "$RTK_VERSION" | cut -d. -f2)
# 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
# v3 hook requires >= 0.31.0 for --hook mode; fall back to v2 jq protocol
if [ "$MAJOR" -eq 0 ] && [ "$MINOR" -lt 31 ]; then
# ── v2 fallback (requires jq) ────────────────────────
if ! command -v jq &>/dev/null; then
echo "[rtk] WARNING: rtk $RTK_VERSION needs jq for hook. Upgrade rtk or install jq." >&2
exit 0
fi
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[ -z "$CMD" ] && exit 0
REWRITTEN=$("$RTK_BIN" rewrite "$CMD" 2>/dev/null) || exit 0
[ "$CMD" = "$REWRITTEN" ] && exit 0
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}}'
exit 0
fi
fi

INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

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

# No change — nothing to do.
if [ "$CMD" = "$REWRITTEN" ]; then
exit 0
fi

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
}
}'
# ── Primary path: rtk handles all JSON (no jq needed) ───
exec cat | "$RTK_BIN" rewrite --hook
80 changes: 80 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"dependencies": {
"diff2html": "^3.4.56"
}
}
51 changes: 51 additions & 0 deletions scripts/test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,57 @@ assert_fails "rewrite gh --jq skip" rtk rewrit
assert_fails "rewrite gh --template skip" rtk rewrite "gh pr view 1 --template '{{.title}}'"
assert_contains "rewrite gh normal works" "rtk gh pr list" rtk rewrite "gh pr list"

section "Rewrite --hook mode (#security: PATH poisoning fix)"

# Supported command: should produce JSON with permissionDecision and rewritten command
HOOK_OUT=$(echo '{"tool_input":{"command":"git status"}}' | rtk rewrite --hook 2>/dev/null)
if echo "$HOOK_OUT" | grep -q '"permissionDecision"' && \
echo "$HOOK_OUT" | grep -q '"rtk git status"'; then
PASS=$((PASS + 1))
printf " ${GREEN}PASS${NC} hook mode: supported cmd produces JSON with rewrite\n"
else
FAIL=$((FAIL + 1))
FAILURES+=("hook mode: supported cmd produces JSON with rewrite")
printf " ${RED}FAIL${NC} hook mode: supported cmd produces JSON with rewrite\n"
printf " out: %s\n" "$HOOK_OUT"
fi

# Unsupported command: should produce empty output
HOOK_OUT=$(echo '{"tool_input":{"command":"htop"}}' | rtk rewrite --hook 2>/dev/null)
if [ -z "$HOOK_OUT" ]; then
PASS=$((PASS + 1))
printf " ${GREEN}PASS${NC} hook mode: unsupported cmd produces empty\n"
else
FAIL=$((FAIL + 1))
FAILURES+=("hook mode: unsupported cmd produces empty")
printf " ${RED}FAIL${NC} hook mode: unsupported cmd produces empty\n"
printf " out: %s\n" "$HOOK_OUT"
fi

# Empty stdin: should produce empty output, exit 0
HOOK_OUT=$(echo '' | rtk rewrite --hook 2>/dev/null)
if [ -z "$HOOK_OUT" ]; then
PASS=$((PASS + 1))
printf " ${GREEN}PASS${NC} hook mode: empty stdin produces empty\n"
else
FAIL=$((FAIL + 1))
FAILURES+=("hook mode: empty stdin produces empty")
printf " ${RED}FAIL${NC} hook mode: empty stdin produces empty\n"
printf " out: %s\n" "$HOOK_OUT"
fi

# Malformed JSON: should produce empty output, exit 0
HOOK_OUT=$(echo 'not json' | rtk rewrite --hook 2>/dev/null)
if [ -z "$HOOK_OUT" ]; then
PASS=$((PASS + 1))
printf " ${GREEN}PASS${NC} hook mode: malformed JSON produces empty\n"
else
FAIL=$((FAIL + 1))
FAILURES+=("hook mode: malformed JSON produces empty")
printf " ${RED}FAIL${NC} hook mode: malformed JSON produces empty\n"
printf " out: %s\n" "$HOOK_OUT"
fi

# ── 33. Verify ────────────────────────────────────────

section "Verify"
Expand Down
2 changes: 1 addition & 1 deletion src/hook_check.rs
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
Loading
Loading