diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 66be0cd..6518035 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "passthru", - "version": "0.5.1", + "version": "0.5.2", "description": "Regex-based permission rules for Claude Code via hooks", "owner": { "name": "nnemirovsky" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f945a00..dd9aca7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "passthru", - "version": "0.5.1", + "version": "0.5.2", "description": "Regex-based permission rules for Claude Code via hooks", "license": "MIT" } diff --git a/hooks/common.sh b/hooks/common.sh index c1f1e60..b03d661 100644 --- a/hooks/common.sh +++ b/hooks/common.sh @@ -361,9 +361,9 @@ overlay_available() { # # Mode behavior: # bypassPermissions: always 0 (everything auto-allowed). -# acceptEdits: 0 for Write/Edit/NotebookEdit/MultiEdit when the -# target file_path resolves inside cwd. Non-edit tools -# in acceptEdits return 1. +# acceptEdits: superset of default. 0 for Write/Edit/NotebookEdit/ +# MultiEdit + Read/Grep/Glob/NotebookRead/LS when the +# target path resolves inside cwd. # default (+ empty mode value): 0 for read-only tools # (Read/Grep/Glob/NotebookRead/LS) when the target path # is inside cwd. Everything else returns 1, including @@ -408,6 +408,8 @@ permission_mode_auto_allows() { case "$mode" in acceptEdits) + # acceptEdits is a superset of default: everything default auto-allows + # (Read/Grep/Glob/LS inside cwd) PLUS edit tools inside cwd. case "$tool_name" in Write|Edit|NotebookEdit|MultiEdit) local fp @@ -417,6 +419,25 @@ permission_mode_auto_allows() { fi return 1 ;; + Read|NotebookRead) + local fp + fp="$(jq -r '.file_path // .notebook_path // ""' <<<"$tool_input" 2>/dev/null || printf '')" + if _pm_path_inside_cwd "$fp" "$cwd"; then + return 0 + fi + return 1 + ;; + Grep|Glob|LS) + local gp + gp="$(jq -r '.path // ""' <<<"$tool_input" 2>/dev/null || printf '')" + if [ -z "$gp" ]; then + return 0 + fi + if _pm_path_inside_cwd "$gp" "$cwd"; then + return 0 + fi + return 1 + ;; *) return 1 ;; diff --git a/hooks/handlers/pre-tool-use.sh b/hooks/handlers/pre-tool-use.sh index d53c703..b0dabc4 100755 --- a/hooks/handlers/pre-tool-use.sh +++ b/hooks/handlers/pre-tool-use.sh @@ -487,7 +487,7 @@ fi # that should never trigger the overlay. Pass them through unconditionally. if [ "$MATCHED" != "ask" ]; then case "$TOOL_NAME" in - ToolSearch|TaskCreate|TaskUpdate|TaskGet|TaskList|TaskOutput|TaskStop|\ + ToolSearch|Skill|TaskCreate|TaskUpdate|TaskGet|TaskList|TaskOutput|TaskStop|\ AskUserQuestion|SendMessage|EnterPlanMode|ExitPlanMode|ScheduleWakeup|\ CronCreate|CronDelete|CronList|Monitor|LSP|RemoteTrigger|\ EnterWorktree|ExitWorktree|TeamCreate|TeamDelete) @@ -497,12 +497,38 @@ if [ "$MATCHED" != "ask" ]; then esac fi -# --- 8. Overlay path ------------------------------------------------------- -# Passthru handles ALL non-internal tool calls. There is no mode-based -# auto-allow shortcut. Every unmatched call goes to the overlay so the user -# always sees a prompt. CC's native dialog only fires as a fallback when the -# user explicitly cancels the overlay (Esc) or the overlay is unavailable. -# +# --- 8. Mode-based auto-allow ----------------------------------------------- +# Replicate CC's per-mode auto-allow logic within passthru. Calls that CC +# would silently approve (e.g. Read inside cwd in default mode, Write inside +# cwd in acceptEdits mode) get an explicit allow from passthru so the overlay +# does not fire for routine operations. Passthru emits allow (not continue), +# keeping the decision on our side rather than falling through to CC. +if [ "$MATCHED" != "ask" ]; then + if permission_mode_auto_allows "$PERMISSION_MODE" "$TOOL_NAME" "$TOOL_INPUT" "$CC_CWD" 2>/dev/null; then + MSG="passthru mode-allow: ${PERMISSION_MODE:-default}" + emit_decision "allow" "$MSG" + audit_write_line "allow" "$TOOL_NAME" "mode:${PERMISSION_MODE:-default}" "" "" "$TOOL_USE_ID" "passthru-mode" + exit 0 + fi +fi + +# --- 9. Write tools -> native dialog (for diff rendering) ------------------ +# Write/Edit/NotebookEdit that weren't mode-auto-allowed (step 8) should +# fall through to CC's native dialog which renders diffs. The overlay can't +# show diffs, so forcing Esc for every edit is bad UX. An explicit ask-rule +# match still routes to the overlay (user opted in). +if [ "$MATCHED" != "ask" ]; then + case "$TOOL_NAME" in + Write|Edit|NotebookEdit|MultiEdit) + emit_decision "ask" "passthru: write tool, deferring to native dialog for diff" + audit_write_line "ask" "$TOOL_NAME" "write-tool native fallback" "" "" "$TOOL_USE_ID" + audit_write_breadcrumb "$TOOL_USE_ID" "$TOOL_NAME" "$TOOL_INPUT" + exit 0 + ;; + esac +fi + +# --- 10. Overlay path ------------------------------------------------------ # Reached when either: # * an ask[] rule matched, or # * no rule matched AND mode did NOT auto-allow. @@ -582,6 +608,7 @@ fi export PASSTHRU_OVERLAY_RESULT_FILE="$OVERLAY_RESULT" export PASSTHRU_OVERLAY_TOOL_NAME="$TOOL_NAME" export PASSTHRU_OVERLAY_TOOL_INPUT_JSON="$TOOL_INPUT" +export PASSTHRU_OVERLAY_CWD="$CC_CWD" # Invoke the overlay and capture its exit code. We have an ERR trap in place # (converts unexpected errors to fail-open passthrough), so we cannot rely on diff --git a/hooks/hooks.json b/hooks/hooks.json index b89c467..387a110 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -8,7 +8,7 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/handlers/pre-tool-use.sh", - "timeout": 75 + "timeout": 300 } ] } diff --git a/scripts/overlay-dialog.sh b/scripts/overlay-dialog.sh index 5459a34..ed92b5d 100755 --- a/scripts/overlay-dialog.sh +++ b/scripts/overlay-dialog.sh @@ -54,6 +54,7 @@ TOOL_NAME="${PASSTHRU_OVERLAY_TOOL_NAME:-}" TOOL_INPUT_JSON="${PASSTHRU_OVERLAY_TOOL_INPUT_JSON:-}" RESULT_FILE="${PASSTHRU_OVERLAY_RESULT_FILE:-}" TIMEOUT="${PASSTHRU_OVERLAY_TIMEOUT:-60}" +OVERLAY_CWD="${PASSTHRU_OVERLAY_CWD:-${PWD}}" TEST_ANSWER="${PASSTHRU_OVERLAY_TEST_ANSWER:-}" # Without a result file path we have nowhere to write. Bail silently (caller @@ -178,48 +179,154 @@ MENU_KEYS=(y a n d esc) MENU_COUNT=${#MENU_LABELS[@]} selected=0 -# Build a human-readable preview from tool_input. Extract the most relevant -# field per tool type instead of showing raw JSON. -preview="" +# Build a human-readable preview from tool_input. Each tool type gets a +# tailored display. MCP tools show pretty JSON. Edits show a diff preview. +_extract() { jq -r --arg f "$1" '.[$f] // empty' <<<"$TOOL_INPUT_JSON" 2>/dev/null; } +_truncate() { + local s="$1" max="${2:-120}" + if [ "${#s}" -gt "$max" ]; then + printf '%s...' "${s:0:$((max - 3))}" + else + printf '%s' "$s" + fi +} + +# preview_lines: array of lines to display. Populated per tool type. +preview_lines=() +extra_height=0 # additional lines beyond standard 1-line preview + if [ -n "$TOOL_INPUT_JSON" ]; then - _extract() { jq -r --arg f "$1" '.[$f] // empty' <<<"$TOOL_INPUT_JSON" 2>/dev/null; } case "$TOOL_NAME" in Bash) - preview="$(_extract command)" ;; - WebFetch|WebSearch) - preview="$(_extract url)" - [ -z "$preview" ] && preview="$(_extract query)" ;; - Read|Edit|Write|NotebookEdit|NotebookRead) - preview="$(_extract file_path)" ;; + preview_lines+=("$(_truncate "$(_extract command)" 120)") + ;; + WebFetch) + preview_lines+=("$(_extract url)") + ;; + WebSearch) + _q="$(_extract query)" + [ -n "$_q" ] && preview_lines+=("search: $_q") || preview_lines+=("$(_extract url)") + ;; + Edit|Write) + preview_lines+=("$(_extract file_path)") + ;; + Read|NotebookRead) + preview_lines+=("$(_extract file_path)") + ;; + NotebookEdit) + _fp="$(_extract file_path)" + _cell="$(_extract cell_id)" + preview_lines+=("$_fp") + [ -n "$_cell" ] && preview_lines+=("cell: $_cell") && extra_height=1 + ;; Grep) - preview="$(_extract pattern)" + _pat="$(_extract pattern)" _path="$(_extract path)" - [ -n "$_path" ] && preview="${preview} (in ${_path})" ;; + preview_lines+=("/$_pat/") + [ -n "$_path" ] && preview_lines+=("in: $_path") && extra_height=1 + ;; Glob) - preview="$(_extract pattern)" + _pat="$(_extract pattern)" _path="$(_extract path)" - [ -n "$_path" ] && preview="${preview} (in ${_path})" ;; + preview_lines+=("$_pat") + [ -n "$_path" ] && preview_lines+=("in: $_path") && extra_height=1 + ;; + Skill) + _skill="$(_extract skill)" + _args="$(_extract args)" + if [ -n "$_args" ]; then + preview_lines+=("$_skill $_args") + else + preview_lines+=("$_skill") + fi + ;; Agent) - preview="$(_extract description)" - [ -z "$preview" ] && preview="$(_extract prompt | head -c 120)" ;; + _desc="$(_extract description)" + [ -n "$_desc" ] && preview_lines+=("$_desc") || preview_lines+=("$(_truncate "$(_extract prompt)" 120)") + ;; + mcp__*) + # MCP tools: pretty-print the JSON args with indentation. + _pretty="$(jq -r '.' <<<"$TOOL_INPUT_JSON" 2>/dev/null || echo "$TOOL_INPUT_JSON")" + _line_count=0 + _total_lines=0 + while IFS= read -r _line; do + _total_lines=$((_total_lines + 1)) + if [ "$_line_count" -lt 10 ]; then + preview_lines+=("$_line") + _line_count=$((_line_count + 1)) + fi + done <<<"$_pretty" + if [ "$_total_lines" -gt 10 ]; then + _remaining=$((_total_lines - 10)) + preview_lines+=(" ... (${_remaining} more lines)") + _line_count=$((_line_count + 1)) + fi + extra_height=$((_line_count - 1)) + ;; *) - preview="$TOOL_INPUT_JSON" ;; + preview_lines+=("$(_truncate "$TOOL_INPUT_JSON" 120)") + ;; esac - # Fallback to raw JSON if extraction yielded nothing. - [ -z "$preview" ] && preview="$TOOL_INPUT_JSON" - max_preview=120 - truncated_len=$((max_preview - 3)) - if [ "${#preview}" -gt "$max_preview" ]; then - preview="${preview:0:$truncated_len}..." - fi fi +# Fallback if nothing was extracted. +if [ "${#preview_lines[@]}" -eq 0 ]; then + preview_lines+=("$(_truncate "$TOOL_INPUT_JSON" 120)") +fi + +# Session context for the header (helps distinguish multiple CC sessions). +_display_cwd="${OVERLAY_CWD:-${PWD:-}}" +if [ -z "$_display_cwd" ]; then + _display_cwd="$(pwd 2>/dev/null || true)" +fi +case "$_display_cwd" in + "${HOME:-/nonexistent}"/*) _display_cwd="~${_display_cwd#"${HOME}"}" ;; + "${HOME:-/nonexistent}") _display_cwd="~" ;; +esac +# Session label: multiplexer window/tab name if available. +_session_label="" +if [ -n "${TMUX:-}" ]; then + _session_label="$(tmux display-message -p '#W' 2>/dev/null || true)" +elif [ -n "${KITTY_WINDOW_ID:-}" ]; then + _session_label="$(kitty @ get-text --match "id:${KITTY_WINDOW_ID}" 2>/dev/null | head -c 0 ; kitty @ ls 2>/dev/null | jq -r --arg id "$KITTY_WINDOW_ID" '[.[].tabs[].windows[] | select(.id == ($id | tonumber))][0].title // empty' 2>/dev/null || true)" +elif [ -n "${WEZTERM_PANE:-}" ]; then + _session_label="$(wezterm cli list --format json 2>/dev/null | jq -r --arg p "$WEZTERM_PANE" '.[] | select(.pane_id == ($p | tonumber)) | .title // empty' 2>/dev/null || true)" +fi + +_render_header() { + # cwd on top right, session below if available. No empty lines. + local _ctx="" + if [ -n "$_display_cwd" ]; then + _ctx="$_display_cwd" + fi + if [ -n "$_session_label" ] && [ "$_session_label" != "$_display_cwd" ]; then + if [ -n "$_ctx" ]; then + _ctx="${_ctx} | ${_session_label}" + else + _ctx="$_session_label" + fi + fi + if [ -n "$_ctx" ]; then + printf "${BOLD}Passthru Permission Prompt${RESET} \033[2m%s\033[0m\n\n" "$_ctx" + else + printf "${BOLD}Passthru Permission Prompt${RESET}\n\n" + fi +} render_main_menu() { - # Move cursor to top-left and clear screen. printf '\033[H\033[2J' - printf "${BOLD}Passthru Permission Prompt${RESET}\n\n" + _render_header printf "Tool: ${CYAN}%s${RESET}\n" "${TOOL_NAME:-(unknown)}" - printf "Input: ${DIM}%s${RESET}\n\n" "$preview" + # Render preview lines. + local first=1 + for _pline in "${preview_lines[@]}"; do + if [ "$first" -eq 1 ]; then + printf "Input: ${DIM}%b${RESET}\n" "$_pline" + first=0 + else + printf " ${DIM}%b${RESET}\n" "$_pline" + fi + done + printf '\n' local i for ((i = 0; i < MENU_COUNT; i++)); do @@ -301,19 +408,40 @@ fi proposed="$(propose_rule)" -CONFIRM_LABELS=("[Enter] Accept rule" "[E] Edit rule JSON" "[Esc] Back to menu") -CONFIRM_KEYS=(enter e esc) -CONFIRM_COUNT=${#CONFIRM_LABELS[@]} -confirm_sel=0 +# Extract tool regex and match fields from the proposed rule for two-field editing. +prop_tool="$(jq -r '.tool // ""' <<<"$proposed" 2>/dev/null)" +prop_match_key="$(jq -r '.match // empty | keys[0] // empty' <<<"$proposed" 2>/dev/null)" +prop_match_val="$(jq -r '.match // empty | to_entries[0].value // empty' <<<"$proposed" 2>/dev/null)" -render_confirm_menu() { +render_rule_editor() { printf '\033[H\033[2J' - printf "${BOLD}Passthru Permission Prompt${RESET}\n\n" + _render_header "$_display_cwd" printf "Tool: ${CYAN}%s${RESET}\n" "${TOOL_NAME:-(unknown)}" - printf "Input: ${DIM}%s${RESET}\n\n" "$preview" + local _first=1 + for _pl in "${preview_lines[@]}"; do + if [ "$_first" -eq 1 ]; then + printf "Input: ${DIM}%b${RESET}\n" "$_pl" + _first=0 + else + printf " ${DIM}%b${RESET}\n" "$_pl" + fi + done + printf '\n' printf "Suggested rule:\n" - printf " ${GREEN}%s${RESET}\n\n" "$proposed" + printf " Tool regex: ${GREEN}%s${RESET}\n" "$prop_tool" + if [ -n "$prop_match_key" ]; then + printf " Match %-6s ${GREEN}%s${RESET}\n" "${prop_match_key}:" "$prop_match_val" + fi + printf '\n' +} +CONFIRM_LABELS=("[Enter] Accept rule" "[E] Edit fields" "[Esc] Back to menu") +CONFIRM_KEYS=(enter e esc) +CONFIRM_COUNT=${#CONFIRM_LABELS[@]} +confirm_sel=0 + +render_confirm_screen() { + render_rule_editor local i for ((i = 0; i < CONFIRM_COUNT; i++)); do if [ "$i" -eq "$confirm_sel" ]; then @@ -325,75 +453,82 @@ render_confirm_menu() { printf "\n\033[2mUse arrow keys or press a letter key\033[0m\n" } -render_confirm_menu +render_confirm_screen while true; do read_key case "$KEY" in up) - if [ "$confirm_sel" -gt 0 ]; then - confirm_sel=$((confirm_sel - 1)) - else - confirm_sel=$((CONFIRM_COUNT - 1)) - fi - render_confirm_menu + confirm_sel=$(( (confirm_sel - 1 + CONFIRM_COUNT) % CONFIRM_COUNT )) + render_confirm_screen ;; down) - if [ "$confirm_sel" -lt $((CONFIRM_COUNT - 1)) ]; then - confirm_sel=$((confirm_sel + 1)) - else - confirm_sel=0 - fi - render_confirm_menu + confirm_sel=$(( (confirm_sel + 1) % CONFIRM_COUNT )) + render_confirm_screen ;; enter) case "${CONFIRM_KEYS[$confirm_sel]}" in - enter) - write_verdict_always "$answer" "$proposed" - exit 0 - ;; + enter) break ;; e) - # Edit path below. - break + # Two-field editor below. + printf '\033[H\033[2J' + printf "${BOLD}Edit Rule${RESET}\n\n" + printf "Edit each field (pre-filled, use arrow keys to navigate).\n\n" + printf "Tool regex: " + edited_tool="" + IFS= read -r -e -i "$prop_tool" -t "$TIMEOUT" edited_tool || true + [ -z "$edited_tool" ] && edited_tool="$prop_tool" + if [ -n "$prop_match_key" ]; then + printf "Match %s: " "$prop_match_key" + edited_match="" + IFS= read -r -e -i "$prop_match_val" -t "$TIMEOUT" edited_match || true + [ -z "$edited_match" ] && edited_match="$prop_match_val" + prop_match_val="$edited_match" + fi + prop_tool="$edited_tool" + # Rebuild and re-render. + render_confirm_screen ;; esc) - # Back to main menu. Re-run entire script via exec for simplicity. exec bash "$0" ;; esac ;; e) - break # fall through to edit + # Two-field editor (shortcut). + printf '\033[H\033[2J' + printf "${BOLD}Edit Rule${RESET}\n\n" + printf "Edit each field. Leave blank to keep the suggested value.\n\n" + printf "Tool regex ${DIM}[%s]${RESET}: " "$prop_tool" + edited_tool="" + IFS= read -r -e -t "$TIMEOUT" edited_tool || true + [ -z "$edited_tool" ] && edited_tool="$prop_tool" + if [ -n "$prop_match_key" ]; then + printf "Match %s ${DIM}[%s]${RESET}: " "$prop_match_key" "$prop_match_val" + edited_match="" + IFS= read -r -e -t "$TIMEOUT" edited_match || true + [ -z "$edited_match" ] && edited_match="$prop_match_val" + prop_match_val="$edited_match" + fi + prop_tool="$edited_tool" + render_confirm_screen ;; esc|timeout) - # Back to main menu. exec bash "$0" ;; *) - # Unknown key: ignore. ;; esac done -# Edit path: read a full line with readline. -printf '\033[H\033[2J' -printf "${BOLD}Edit Rule JSON${RESET}\n\n" -printf "Current:\n ${GREEN}%s${RESET}\n\n" "$proposed" -printf "Type new JSON (leave blank to accept):\n" -edited="" -if ! IFS= read -r -e -t "$TIMEOUT" edited; then - exit 0 -fi -if [ -z "$edited" ]; then - write_verdict_always "$answer" "$proposed" -elif jq -e 'type == "object"' >/dev/null 2>&1 <<<"$edited"; then - write_verdict_always "$answer" "$edited" +# Build the final rule JSON from the (possibly edited) fields. +if [ -n "$prop_match_key" ] && [ -n "$prop_match_val" ]; then + final_rule="$(jq -cn --arg t "$prop_tool" --arg k "$prop_match_key" --arg v "$prop_match_val" \ + '{tool: $t, match: {($k): $v}}')" else - printf '\n${RED}Invalid JSON (must be an object)${RESET}\n' - printf 'Using suggested rule: %s\n' "$proposed" - sleep 2 - write_verdict_always "$answer" "$proposed" + final_rule="$(jq -cn --arg t "$prop_tool" '{tool: $t}')" fi +write_verdict_always "$answer" "$final_rule" exit 0 diff --git a/scripts/overlay.sh b/scripts/overlay.sh index 29ed8d3..0354488 100755 --- a/scripts/overlay.sh +++ b/scripts/overlay.sh @@ -131,23 +131,34 @@ _write_env_file() { printf 'export PASSTHRU_OVERLAY_TOOL_NAME=%q\n' "${PASSTHRU_OVERLAY_TOOL_NAME:-}" >> "$_ENV_FILE" printf 'export PASSTHRU_OVERLAY_TOOL_INPUT_JSON=%q\n' "${PASSTHRU_OVERLAY_TOOL_INPUT_JSON:-}" >> "$_ENV_FILE" printf 'export PASSTHRU_OVERLAY_TIMEOUT=%q\n' "$TIMEOUT" >> "$_ENV_FILE" + printf 'export PASSTHRU_OVERLAY_CWD=%q\n' "${PASSTHRU_OVERLAY_CWD:-${PWD}}" >> "$_ENV_FILE" if [ -n "${PASSTHRU_OVERLAY_TEST_ANSWER:-}" ]; then printf 'export PASSTHRU_OVERLAY_TEST_ANSWER=%q\n' "$PASSTHRU_OVERLAY_TEST_ANSWER" >> "$_ENV_FILE" fi } _write_env_file -# Compute popup height dynamically based on content. -_term_cols="$(tput cols 2>/dev/null || echo 120)" -_popup_cols=$(( _term_cols * 80 / 100 )) -[ "$_popup_cols" -lt 40 ] && _popup_cols=40 -_input_str="${PASSTHRU_OVERLAY_TOOL_INPUT_JSON:-}" -_input_len="${#_input_str}" -if [ "$_input_len" -gt 120 ]; then _input_len=120; fi -_input_lines=$(( (_input_len + 7) / (_popup_cols - 8) + 1 )) -[ "$_input_lines" -lt 1 ] && _input_lines=1 -_popup_height=$(( 12 + _input_lines )) -[ "$_popup_height" -lt 13 ] && _popup_height=13 +# Compute popup height dynamically based on content and tool type. +# Base: title(1) + blank(1) + tool(1) + input(varies) + blank(1) + +# 5 menu(5) + blank(1) + hint(1) + cursor(1) = 12 + input_lines +_tool="${PASSTHRU_OVERLAY_TOOL_NAME:-}" +_input="${PASSTHRU_OVERLAY_TOOL_INPUT_JSON:-}" +_extra=0 +case "$_tool" in + NotebookEdit) + # file_path + cell info = 2 lines + _extra=1 ;; + Grep|Glob) + # pattern + path = 2 lines + _extra=1 ;; + mcp__*) + # pretty-printed JSON: estimate lines from key count, cap at 8 + _jlines="$(jq 'length' <<<"$_input" 2>/dev/null || echo 1)" + [ "$_jlines" -gt 10 ] && _jlines=10 + _extra=$((_jlines)) ;; +esac +# +2 for cwd line + optional window/session line in header +_popup_height=$((15 + _extra)) [ "$_popup_height" -gt 30 ] && _popup_height=30 launch_tmux() { diff --git a/scripts/remove-rule.sh b/scripts/remove-rule.sh index 4211e74..17ead88 100755 --- a/scripts/remove-rule.sh +++ b/scripts/remove-rule.sh @@ -159,9 +159,20 @@ acquire_lock() { deadline=$(( $(date +%s) + LOCK_TIMEOUT )) while :; do if mkdir "$LOCK_DIR" 2>/dev/null; then + printf '%s\n' "$$" > "$LOCK_DIR/pid" 2>/dev/null || true LOCK_HELD=1 return 0 fi + # Stale lock detection: check if holder PID is still alive. + if [ -f "$LOCK_DIR/pid" ]; then + local holder_pid + holder_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" + if [ -n "$holder_pid" ] && ! kill -0 "$holder_pid" 2>/dev/null; then + rm -f "$LOCK_DIR/pid" 2>/dev/null || true + rmdir "$LOCK_DIR" 2>/dev/null || true + continue + fi + fi if [ "$(date +%s)" -ge "$deadline" ]; then return 1 fi @@ -171,6 +182,7 @@ acquire_lock() { release_lock() { if [ "$LOCK_HELD" -eq 1 ]; then + rm -f "$LOCK_DIR/pid" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || true LOCK_HELD=0 fi diff --git a/scripts/write-rule.sh b/scripts/write-rule.sh index 7bc4444..1f7d219 100755 --- a/scripts/write-rule.sh +++ b/scripts/write-rule.sh @@ -117,9 +117,25 @@ acquire_lock() { deadline=$(( $(date +%s) + LOCK_TIMEOUT )) while :; do if mkdir "$LOCK_DIR" 2>/dev/null; then + # Write our PID so stale-lock detection can check liveness. + printf '%s\n' "$$" > "$LOCK_DIR/pid" 2>/dev/null || true LOCK_HELD=1 return 0 fi + # Stale lock detection: if the lock dir exists but the holder PID is dead, + # forcibly remove it. This handles the case where a hook was killed by + # CC's timeout mid-write, leaving the lock behind. + if [ -f "$LOCK_DIR/pid" ]; then + local holder_pid + holder_pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" + if [ -n "$holder_pid" ] && ! kill -0 "$holder_pid" 2>/dev/null; then + # Holder is dead. Remove stale lock. + rm -f "$LOCK_DIR/pid" 2>/dev/null || true + rmdir "$LOCK_DIR" 2>/dev/null || true + continue # retry mkdir immediately + fi + fi + # No pid file = old-format lock or race. Just wait and retry. if [ "$(date +%s)" -ge "$deadline" ]; then return 1 fi @@ -129,6 +145,7 @@ acquire_lock() { release_lock() { if [ "$LOCK_HELD" -eq 1 ]; then + rm -f "$LOCK_DIR/pid" 2>/dev/null || true rmdir "$LOCK_DIR" 2>/dev/null || true LOCK_HELD=0 fi diff --git a/tests/hook_handler.bats b/tests/hook_handler.bats index 54a2664..2c23c8d 100644 --- a/tests/hook_handler.bats +++ b/tests/hook_handler.bats @@ -939,32 +939,33 @@ run_handler_in_stub_root() { [ "$decision" = "allow" ] } -@test "mode: acceptEdits + Write OUTSIDE cwd -> overlay path entered" { - # file_path is /tmp/elsewhere (definitely not under PROJ_ROOT). Mode does - # NOT auto-allow, so we fall through to overlay. Stub emits yes_once. - setup_overlay_stub "yes_once" +@test "mode: acceptEdits + Write OUTSIDE cwd -> native dialog (diff rendering)" { + # Write outside cwd is not mode-auto-allowed. Write tools fall through to + # CC's native dialog (permissionDecision: ask) for diff rendering instead + # of the overlay. ti='{"file_path":"/tmp/elsewhere/foo.ts","content":"x"}' payload="$(make_mode_payload 'Write' "$ti" 'acceptEdits' "$PROJ_ROOT")" - run_handler_in_stub_root "$payload" + run_handler "$payload" [ "$status" -eq 0 ] json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" [ -n "$json_line" ] decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" - [ "$decision" = "allow" ] + [ "$decision" = "ask" ] } -@test "mode: acceptEdits + Read (non-edit tool) -> overlay path entered" { - # Read is NOT in the acceptEdits allow-list; acceptEdits only covers - # Write/Edit/NotebookEdit/MultiEdit. A Read call falls through to overlay. - setup_overlay_stub "yes_once" +@test "mode: acceptEdits + Read inside cwd -> mode auto-allow (superset of default)" { + # acceptEdits is a superset of default: it auto-allows everything default + # does (Read/Grep/Glob inside cwd) PLUS edit tools inside cwd. ti="$(jq -cn --arg fp "$PROJ_ROOT/src/foo.ts" '{file_path:$fp}')" payload="$(make_mode_payload 'Read' "$ti" 'acceptEdits' "$PROJ_ROOT")" - run_handler_in_stub_root "$payload" + run_handler "$payload" [ "$status" -eq 0 ] json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" [ -n "$json_line" ] decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"mode-allow"* ]] } # default mode: all tools go to overlay ---------------------------------------- @@ -1021,17 +1022,17 @@ run_handler_in_stub_root() { [ "$decision" = "allow" ] } -@test "mode: plan + Write -> overlay path entered" { - # plan mode restricts writes; the overlay (or native fallback) gates them. - setup_overlay_stub "no_once" +@test "mode: plan + Write -> native dialog (diff rendering)" { + # plan mode restricts writes. Write tools fall through to native dialog + # for diff rendering rather than the overlay. ti="$(jq -cn --arg fp "$PROJ_ROOT/src/foo.ts" '{file_path:$fp,content:"x"}')" payload="$(make_mode_payload 'Write' "$ti" 'plan' "$PROJ_ROOT")" - run_handler_in_stub_root "$payload" + run_handler "$payload" [ "$status" -eq 0 ] json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" [ -n "$json_line" ] decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" - [ "$decision" = "deny" ] + [ "$decision" = "ask" ] } # WebFetch and WebSearch go through the overlay -------------------------------- @@ -1063,21 +1064,17 @@ run_handler_in_stub_root() { # Path-traversal safety ------------------------------------------------------ -@test "mode: acceptEdits + file_path with ../ traversal is NOT auto-allowed" { - # $PROJ_ROOT/../outside literally starts with $PROJ_ROOT/ but resolves - # OUTSIDE cwd. permission_mode_auto_allows must reject these so crafted - # tool_inputs cannot sneak past the prefix check. - setup_overlay_stub "no_once" +@test "mode: acceptEdits + file_path with ../ traversal -> native dialog (not auto-allowed)" { + # Write with ../ traversal is not mode-auto-allowed. Write tools fall + # through to native dialog for diff rendering. ti="$(jq -cn --arg fp "$PROJ_ROOT/../outside/secret.txt" '{file_path:$fp,content:"x"}')" payload="$(make_mode_payload 'Write' "$ti" 'acceptEdits' "$PROJ_ROOT")" - run_handler_in_stub_root "$payload" + run_handler "$payload" [ "$status" -eq 0 ] json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" [ -n "$json_line" ] - # Decision came from overlay (stub returned no_once). Auto-allow was - # rejected -> overlay was consulted -> deny. decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" - [ "$decision" = "deny" ] + [ "$decision" = "ask" ] } @test "mode: symlink inside cwd -> overlay path entered (no auto-allow shortcut)" { @@ -1405,20 +1402,19 @@ EOF [ "$output" = "overlay" ] } -@test "audit: bypassPermissions mode logs source=overlay (no mode auto-allow)" { - # Mode-based auto-allow is removed. bypassPermissions goes through the - # overlay like every other mode and is logged with source=overlay. +@test "audit: bypassPermissions mode logs source=passthru-mode (mode auto-allow)" { + # bypassPermissions auto-allows everything. Passthru emits allow with + # source=passthru-mode, keeping the decision on our side. enable_audit - setup_overlay_stub "yes_once" ti='{"command":"ls"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'bypassPermissions' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tMODE"}')" - run_handler_in_stub_root "$payload" + run_handler "$payload" [ "$status" -eq 0 ] [ -f "$(audit_log)" ] line="$(head -n1 "$(audit_log)")" run jq -r '.source' <<<"$line" - [ "$output" = "overlay" ] + [ "$output" = "passthru-mode" ] run jq -r '.event' <<<"$line" [ "$output" = "allow" ] } diff --git a/tests/plugin_loads.bats b/tests/plugin_loads.bats index db64dee..3946940 100644 --- a/tests/plugin_loads.bats +++ b/tests/plugin_loads.bats @@ -99,15 +99,14 @@ setup() { [[ "$output" == *'pre-tool-use.sh' ]] } -@test "hooks/hooks.json PreToolUse timeout is 75 (overlay + margin)" { - # Task 8 bumped the PreToolUse timeout from 10s to 75s because the hook - # may block synchronously while the overlay dialog waits for user input. - # The overlay's own timeout is 60s, so 75s leaves 15s of margin for - # overlay.sh startup + post-dialog rule write. CC's hook timeout is - # wall-clock, so anything < 60s would kill the hook mid-dialog. +@test "hooks/hooks.json PreToolUse timeout is 300 (overlay interactive budget)" { + # The hook blocks while the overlay dialog waits for user input. The user + # may navigate menus, review rules, and edit regex fields. 300s (5 min) + # gives generous interactive time. The overlay's per-read timeout (60s) + # provides the inner limit. run jq -r '.hooks.PreToolUse[0].hooks[0].timeout' "$REPO_ROOT/hooks/hooks.json" [ "$status" -eq 0 ] - [ "$output" = "75" ] + [ "$output" = "300" ] } @test "hooks/hooks.json has exactly one PostToolUse entry" {