From e6e4cb1d6cd4ba6ec4570ebf37faf0f771cf6a88 Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Sat, 18 Apr 2026 14:02:32 +0800 Subject: [PATCH 1/5] feat(overlay): full command display, smart regex proposal, tmux notification passthrough - overlay-dialog: wrap long commands at terminal width instead of truncating with "..." - propose-rule: when compound command has uncovered segments (via new PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS env var), target the first uncovered segment's first word instead of the full command's first word - pre-tool-use: compute uncovered segments via new compute_unallowed_segments helper in common.sh (filters out readonly auto-allowed + allow/ask matched) - notification: write OSC 777 to /dev/tty instead of stdout (stdout is the hook's JSON response); wrap in DCS tmux; ... ST passthrough when inside tmux so Ghostty can receive the notification through the tmux popup --- hooks/common.sh | 66 ++++++++++++++++++ hooks/handlers/pre-tool-use.sh | 31 ++++++++- scripts/overlay-dialog.sh | 114 ++++++++++++++++++++++++++------ scripts/overlay-propose-rule.sh | 8 +++ tests/common_load.bats | 36 ++++++++++ tests/overlay.bats | 31 +++++++++ 6 files changed, 263 insertions(+), 23 deletions(-) diff --git a/hooks/common.sh b/hooks/common.sh index 44caa60..17df063 100644 --- a/hooks/common.sh +++ b/hooks/common.sh @@ -2186,3 +2186,69 @@ match_all_segments() { return 0 } + +# --------------------------------------------------------------------------- +# compute_unallowed_segments +# --------------------------------------------------------------------------- +# +# Usage: compute_unallowed_segments \ +# +# +# For compound Bash commands that fall through to the overlay, identifies +# which segments are NOT covered by readonly auto-allow or by any allow/ask +# rule. The overlay uses this information to propose a rule targeting only +# the uncovered portion instead of the full command's first word. +# +# A segment is "unallowed" if: +# - It is NOT read-only auto-allowed (either not in readonly list or +# has absolute path outside cwd/allowed_dirs). +# - AND it does not match any allow or ask rule in ordered_entries. +# +# Output (stdout): NUL-separated list of unallowed segment strings. +# Return: 0 always. +compute_unallowed_segments() { + local ordered="$1" + local tool_name="$2" + local cwd="$3" + local allowed_dirs_json="$4" + shift 4 + + local _cus_segments=("$@") + local seg_count="${#_cus_segments[@]}" + [ "$seg_count" -eq 0 ] && return 0 + + local ordered_count + ordered_count="$(jq -r 'if type == "array" then length else 0 end' <<<"$ordered" 2>/dev/null)" + [ -z "$ordered_count" ] && ordered_count=0 + + local seg seg_idx seg_input entry rule mrc matched + for ((seg_idx = 0; seg_idx < seg_count; seg_idx++)); do + seg="${_cus_segments[$seg_idx]}" + + # Skip read-only segments with valid paths. + if is_readonly_command "$seg" \ + && readonly_paths_allowed "$seg" "$cwd" "$allowed_dirs_json"; then + continue + fi + + # Check if it matches any allow/ask rule. + seg_input="$(jq -cn --arg c "$seg" '{command: $c}')" + matched=0 + local i + for ((i = 0; i < ordered_count; i++)); do + entry="$(jq -c --argjson i "$i" '.[$i]' <<<"$ordered" 2>/dev/null)" + rule="$(jq -c '.rule // {}' <<<"$entry" 2>/dev/null)" + mrc=0 + match_rule "$tool_name" "$seg_input" "$rule" || mrc=$? + if [ "$mrc" -eq 0 ]; then + matched=1 + break + fi + done + + if [ "$matched" -eq 0 ]; then + printf '%s\0' "$seg" + fi + done + return 0 +} diff --git a/hooks/handlers/pre-tool-use.sh b/hooks/handlers/pre-tool-use.sh index 314b61a..86b20c4 100755 --- a/hooks/handlers/pre-tool-use.sh +++ b/hooks/handlers/pre-tool-use.sh @@ -751,6 +751,23 @@ export PASSTHRU_OVERLAY_TOOL_NAME="$TOOL_NAME" export PASSTHRU_OVERLAY_TOOL_INPUT_JSON="$TOOL_INPUT" export PASSTHRU_OVERLAY_CWD="$CC_CWD" +# For compound Bash commands falling through to overlay, compute which +# segments are uncovered (not readonly auto-allowed, not matched by any +# allow/ask rule). The overlay uses this to propose a regex targeting only +# the uncovered portion, not the full command's first word. Separator is +# newline since env vars cannot carry NULs portably across multiplexer +# popups. Segments never contain newlines because split_bash_command splits +# at operators and redirections. +PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS="" +if [ "$TOOL_NAME" = "Bash" ] && [ "$BASH_SEGMENT_COUNT" -gt 1 ]; then + _unallowed_raw="$(compute_unallowed_segments "$ORDERED" "$TOOL_NAME" "$CC_CWD" "$ALLOWED_DIRS_JSON" "${BASH_SEGMENTS[@]}" 2>/dev/null || true)" + # Convert NUL separators to newlines for env var transport. + if [ -n "$_unallowed_raw" ]; then + PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS="$(printf '%s' "$_unallowed_raw" | tr '\0' '\n')" + fi +fi +export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS + # --- Overlay queue lock ------------------------------------------------------- # CC can fire multiple PreToolUse hooks concurrently (parallel tool calls). # Only one overlay popup can be visible at a time in a given multiplexer. @@ -787,7 +804,19 @@ done # Send a desktop notification so the user knows a permission prompt is waiting. # OSC 777 is supported by Ghostty, iTerm2, and other modern terminals. -printf '\033]777;notify;passthru;permission prompt: %s\a' "$TOOL_NAME" 2>/dev/null || true +# Write to /dev/tty, not stdout - stdout is captured by CC as the hook's JSON +# response. When running inside tmux, the OSC sequence must be wrapped in +# tmux's "passthrough" escape (DCS tmux; ... ST) to reach the outer terminal. +_notify_msg="passthru: permission prompt: ${TOOL_NAME}" +if [ -e /dev/tty ]; then + if [ -n "${TMUX:-}" ]; then + # tmux wraps: ESC P tmux; ESC ESC \ + # Inner ESC characters must be doubled (ESC ESC) for tmux passthrough. + printf '\033Ptmux;\033\033]777;notify;passthru;%s\a\033\\' "$_notify_msg" > /dev/tty 2>/dev/null || true + else + printf '\033]777;notify;passthru;%s\a' "$_notify_msg" > /dev/tty 2>/dev/null || true + fi +fi # 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/scripts/overlay-dialog.sh b/scripts/overlay-dialog.sh index e3de5ee..324d759 100755 --- a/scripts/overlay-dialog.sh +++ b/scripts/overlay-dialog.sh @@ -184,13 +184,66 @@ selected=0 # 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" + +# _wrap_line: split a string into multiple lines at whitespace boundaries, +# each line fitting within the given width. Falls back to hard-splitting +# for any single run of non-whitespace longer than the width. Emits lines +# via printf '%s\n' so callers can read them with `while IFS= read`. +_wrap_line() { + local s="$1" width="${2:-100}" + # Hard guard against tiny widths or empty input. + [ "$width" -lt 20 ] && width=20 + if [ -z "$s" ] || [ "${#s}" -le "$width" ]; then + printf '%s\n' "$s" + return 0 fi + awk -v w="$width" ' + { + line = "" + n = split($0, words, /[ \t]+/) + for (i = 1; i <= n; i++) { + word = words[i] + if (word == "") continue + if (length(line) == 0) { + # First word on a line. If it itself exceeds width, hard-split it. + while (length(word) > w) { + print substr(word, 1, w) + word = substr(word, w + 1) + } + line = word + } else if (length(line) + 1 + length(word) <= w) { + line = line " " word + } else { + print line + while (length(word) > w) { + print substr(word, 1, w) + word = substr(word, w + 1) + } + line = word + } + } + if (length(line) > 0) print line + } + ' <<<"$s" +} + +# _append_wrapped: push wrapped lines of $1 into preview_lines, updating +# extra_height accordingly. Width defaults to terminal columns - 10 (for +# "Input: " prefix and popup padding) or 100 if tput fails. +_append_wrapped() { + local s="$1" + local cols + cols="$(tput cols 2>/dev/null || echo 0)" + [ "$cols" -lt 40 ] && cols=110 + local width=$((cols - 10)) + local first=1 + while IFS= read -r _wl; do + preview_lines+=("$_wl") + if [ "$first" -eq 0 ]; then + extra_height=$((extra_height + 1)) + fi + first=0 + done < <(_wrap_line "$s" "$width") } # preview_lines: array of lines to display. Populated per tool type. @@ -200,51 +253,68 @@ extra_height=0 # additional lines beyond standard 1-line preview if [ -n "$TOOL_INPUT_JSON" ]; then case "$TOOL_NAME" in Bash) - preview_lines+=("$(_truncate "$(_extract command)" 120)") + _append_wrapped "$(_extract command)" ;; WebFetch) - preview_lines+=("$(_extract url)") + _append_wrapped "$(_extract url)" ;; WebSearch) _q="$(_extract query)" - [ -n "$_q" ] && preview_lines+=("search: $_q") || preview_lines+=("$(_extract url)") + if [ -n "$_q" ]; then + _append_wrapped "search: $_q" + else + _append_wrapped "$(_extract url)" + fi ;; Edit|Write) - preview_lines+=("$(_extract file_path)") + _append_wrapped "$(_extract file_path)" ;; Read|NotebookRead) - preview_lines+=("$(_extract file_path)") + _append_wrapped "$(_extract file_path)" ;; NotebookEdit) _fp="$(_extract file_path)" _cell="$(_extract cell_id)" - preview_lines+=("$_fp") - [ -n "$_cell" ] && preview_lines+=("cell: $_cell") && extra_height=1 + _append_wrapped "$_fp" + if [ -n "$_cell" ]; then + preview_lines+=("cell: $_cell") + extra_height=$((extra_height + 1)) + fi ;; Grep) _pat="$(_extract pattern)" _path="$(_extract path)" - preview_lines+=("/$_pat/") - [ -n "$_path" ] && preview_lines+=("in: $_path") && extra_height=1 + _append_wrapped "/$_pat/" + if [ -n "$_path" ]; then + preview_lines+=("in: $_path") + extra_height=$((extra_height + 1)) + fi ;; Glob) _pat="$(_extract pattern)" _path="$(_extract path)" - preview_lines+=("$_pat") - [ -n "$_path" ] && preview_lines+=("in: $_path") && extra_height=1 + _append_wrapped "$_pat" + if [ -n "$_path" ]; then + preview_lines+=("in: $_path") + extra_height=$((extra_height + 1)) + fi ;; Skill) _skill="$(_extract skill)" _args="$(_extract args)" if [ -n "$_args" ]; then - preview_lines+=("$_skill $_args") + _append_wrapped "$_skill $_args" else - preview_lines+=("$_skill") + _append_wrapped "$_skill" fi ;; Agent) _desc="$(_extract description)" - [ -n "$_desc" ] && preview_lines+=("$_desc") || preview_lines+=("$(_truncate "$(_extract prompt)" 120)") + if [ -n "$_desc" ]; then + _append_wrapped "$_desc" + else + _append_wrapped "$(_extract prompt)" + fi ;; mcp__*) # MCP tools: pretty-print the JSON args with indentation. @@ -266,13 +336,13 @@ if [ -n "$TOOL_INPUT_JSON" ]; then extra_height=$((_line_count - 1)) ;; *) - preview_lines+=("$(_truncate "$TOOL_INPUT_JSON" 120)") + _append_wrapped "$TOOL_INPUT_JSON" ;; esac fi # Fallback if nothing was extracted. if [ "${#preview_lines[@]}" -eq 0 ]; then - preview_lines+=("$(_truncate "$TOOL_INPUT_JSON" 120)") + _append_wrapped "$TOOL_INPUT_JSON" fi # Session context for the header (helps distinguish multiple CC sessions). diff --git a/scripts/overlay-propose-rule.sh b/scripts/overlay-propose-rule.sh index d468ddf..0b5dc3c 100755 --- a/scripts/overlay-propose-rule.sh +++ b/scripts/overlay-propose-rule.sh @@ -85,6 +85,14 @@ if [ "$TOOL_NAME" = "Bash" ]; then emit_fallback exit 0 fi + # If the hook identified specific uncovered segments (compound command + # where some parts are already auto-allowed), target the first uncovered + # segment instead of the whole command. This produces a useful rule for + # the part that actually needs it. + if [ -n "${PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS:-}" ]; then + # Take the first non-empty line. + cmd="$(printf '%s\n' "$PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS" | awk 'NF{print; exit}')" + fi # First token = first word of the command. Trim leading whitespace then # split on whitespace. cmd="${cmd#"${cmd%%[![:space:]]*}"}" diff --git a/tests/common_load.bats b/tests/common_load.bats index fe3cac8..0a37344 100644 --- a/tests/common_load.bats +++ b/tests/common_load.bats @@ -466,3 +466,39 @@ EOF run validate_rules "$merged" [ "$status" -eq 0 ] } + +# --------------------------------------------------------------------------- +# compute_unallowed_segments +# --------------------------------------------------------------------------- + +@test "compute_unallowed_segments: readonly segment is filtered out" { + # Two segments: ls (readonly) and weird_cmd (not readonly, no rule). + # Only weird_cmd should be returned. + result="$(compute_unallowed_segments '[]' "Bash" "$PROJ_ROOT" "[]" "ls" "weird_cmd --flag" | tr '\0' '\n')" + [ "$result" = "weird_cmd --flag" ] +} + +@test "compute_unallowed_segments: allow-rule segment is filtered out" { + # Ordered allow/ask list covers 'git' but not 'weird_cmd'. + ordered='[{"list":"allow","merged_idx":0,"rule":{"tool":"Bash","match":{"command":"^git\\b"}}}]' + result="$(compute_unallowed_segments "$ordered" "Bash" "$PROJ_ROOT" "[]" "git status" "weird_cmd" | tr '\0' '\n')" + [ "$result" = "weird_cmd" ] +} + +@test "compute_unallowed_segments: all readonly returns empty" { + # All segments readonly -> returns empty. + result="$(compute_unallowed_segments '[]' "Bash" "$PROJ_ROOT" "[]" "ls" "cat README.md" | tr '\0' '\n')" + [ -z "$result" ] +} + +@test "compute_unallowed_segments: no rules and no readonly returns all" { + # Nothing covers the segments -> all are unallowed. + result="$(compute_unallowed_segments '[]' "Bash" "$PROJ_ROOT" "[]" "weird_a" "weird_b" | tr '\0' '\n')" + [ "$result" = "$(printf 'weird_a\nweird_b')" ] +} + +@test "compute_unallowed_segments: readonly with invalid path is not filtered" { + # cat with absolute path outside cwd -> not readonly-allowed -> uncovered. + result="$(compute_unallowed_segments '[]' "Bash" "$PROJ_ROOT" "[]" "cat /etc/passwd" | tr '\0' '\n')" + [ "$result" = "cat /etc/passwd" ] +} diff --git a/tests/overlay.bats b/tests/overlay.bats index 7ce7612..9c38e09 100644 --- a/tests/overlay.bats +++ b/tests/overlay.bats @@ -282,6 +282,37 @@ restricted_path() { [ "$output" = '^rm(\s[^<>()\$\x60|{}&;\n\r]*)?$' ] } +@test "propose-rule: Bash uses unallowed segments env var when set" { + # Compound command where ls is auto-allowed but weird_cmd is not. + # The proposer should target weird_cmd, not ls. + export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS="weird_cmd --flag" + run bash "$PROPOSER" "Bash" '{"command":"ls && weird_cmd --flag"}' + unset PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS + [ "$status" -eq 0 ] + run jq -r '.match.command' <<<"$output" + [ "$output" = '^weird_cmd(\s[^<>()\$\x60|{}&;\n\r]*)?$' ] +} + +@test "propose-rule: Bash uses first non-empty unallowed segment" { + # Multiple unallowed segments; should use the first. + export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS=$'npm test\ncargo build' + run bash "$PROPOSER" "Bash" '{"command":"ls && npm test && cargo build"}' + unset PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS + [ "$status" -eq 0 ] + run jq -r '.match.command' <<<"$output" + [ "$output" = '^npm(\s[^<>()\$\x60|{}&;\n\r]*)?$' ] +} + +@test "propose-rule: Bash ignores empty unallowed segments env var" { + # Empty env var should fall back to using the whole command's first word. + export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS="" + run bash "$PROPOSER" "Bash" '{"command":"git status"}' + unset PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS + [ "$status" -eq 0 ] + run jq -r '.match.command' <<<"$output" + [ "$output" = '^git(\s[^<>()\$\x60|{}&;\n\r]*)?$' ] +} + @test "propose-rule: Read file_path -> parent-dir prefix match" { run bash "$PROPOSER" "Read" '{"file_path":"/Users/me/proj/src/file.ts"}' [ "$status" -eq 0 ] From b85ea655b9a5849e856484126bdd50fac3dc36e7 Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Sat, 18 Apr 2026 14:13:30 +0800 Subject: [PATCH 2/5] fix(compound): readonly segments count as covered in compound allow matching `go test ./... | tail -50` with user rule `^go` used to fall through to overlay because `tail -50` had no allow rule. But tail IS readonly. Fix: before calling match_all_segments on compound Bash commands, pre-filter out segments that are readonly-auto-allowed (match readonly regex + paths inside cwd/allowed_dirs). The filter is gated on has_redirect=false so `cat file > /tmp/out && ls` still falls through when has_redirect would have blocked the readonly step. Also adds regression tests: - readonly segment covers for compound allow - filter respects has_redirect guard - 2>&1 fd duplication does not trigger has_redirect --- hooks/handlers/pre-tool-use.sh | 36 +++++++++++++++++-- tests/hook_handler.bats | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/hooks/handlers/pre-tool-use.sh b/hooks/handlers/pre-tool-use.sh index 86b20c4..f8bc798 100755 --- a/hooks/handlers/pre-tool-use.sh +++ b/hooks/handlers/pre-tool-use.sh @@ -538,10 +538,42 @@ ORDERED_COUNT="$(jq -r 'if type == "array" then length else 0 end' <<<"$ORDERED" MATCHED="" # "allow" | "ask" | "" if [ "$ORDERED_COUNT" -gt 0 ]; then if [ "$TOOL_NAME" = "Bash" ] && [ "$BASH_SEGMENT_COUNT" -gt 1 ]; then - # Compound Bash command: use per-segment matching algorithm. + # Compound Bash command: pre-filter readonly-auto-allowed segments + # before per-segment matching. A segment is "covered" if it is either + # readonly-auto-allowed OR matches an allow/ask rule. Filtering readonly + # segments out lets match_all_segments make its all-or-nothing decision + # on the remaining non-readonly segments. + # + # Example: `go test ./... | tail -50` with user rule `^go` and `tail` + # in readonly list -> without filter, tail has no rule and command + # falls through to overlay. With filter, tail is dropped, go matches + # its rule, command is allowed. + # + # Guard: only filter when the ORIGINAL command has no output redirects. + # If `cat file > /tmp/x` were filtered as readonly, we would mask the + # write. has_redirect blocks this case by keeping the full segment list. + _FILTERED_SEGMENTS=() + if ! has_redirect "$BASH_CMD"; then + for _seg in "${BASH_SEGMENTS[@]}"; do + if is_readonly_command "$_seg" \ + && readonly_paths_allowed "$_seg" "$CC_CWD" "$ALLOWED_DIRS_JSON"; then + continue + fi + _FILTERED_SEGMENTS+=("$_seg") + done + else + _FILTERED_SEGMENTS=("${BASH_SEGMENTS[@]}") + fi + + # If filtering left 0 segments, all were readonly and step 5b should + # have handled it. Defensive fallback: use the original segments. + if [ "${#_FILTERED_SEGMENTS[@]}" -eq 0 ]; then + _FILTERED_SEGMENTS=("${BASH_SEGMENTS[@]}") + fi + _MAS_RESULT="" _mas_rc=0 - _MAS_RESULT="$(match_all_segments "$ORDERED" "$TOOL_NAME" "${BASH_SEGMENTS[@]}" 2>/dev/null)" || _mas_rc=$? + _MAS_RESULT="$(match_all_segments "$ORDERED" "$TOOL_NAME" "${_FILTERED_SEGMENTS[@]}" 2>/dev/null)" || _mas_rc=$? if [ "$_mas_rc" -eq 2 ]; then printf '[passthru] compound allow/ask rule regex error; passing through\n' >&2 emit_passthrough diff --git a/tests/hook_handler.bats b/tests/hook_handler.bats index 5c71aa5..7a0247e 100644 --- a/tests/hook_handler.bats +++ b/tests/hook_handler.bats @@ -1671,6 +1671,71 @@ EOF [ "$decision" = "ask" ] } +@test "compound: readonly segment covers for compound allow (go test | tail)" { + # Regression: `go test ./... | tail -50` with user rule `^go` should + # allow the whole compound. tail is readonly; with the pre-filter it + # is dropped from match_all_segments and go matches its rule. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^go(\\s|$)" }, "reason": "allow go" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"go test ./... -timeout 60s | tail -50"}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [[ "$reason" == *"allow go"* ]] +} + +@test "compound: readonly segment filter respects has_redirect guard" { + # `cat file > /tmp/out && ls` has an output redirect. The filter must + # NOT drop segments when has_redirect=true, otherwise we would mask + # the write. Without user rules, this should fall through to ask. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^cat(\\s|$)" }, "reason": "allow cat" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"cat file > /tmp/out && ls"}}' + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + # ls is readonly. Without the guard, filter would drop ls leaving cat, + # and cat matches the rule -> allow. With the guard (has_redirect=true), + # both segments stay. cat matches, but ls has no allow rule -> fall + # through to ask. This prevents masking the write via the redirect. + [ "$decision" = "ask" ] +} + +@test "compound: fd duplication (2>&1) does not trigger has_redirect" { + # `go test 2>&1 | head` has no file redirect, only fd duplication. + # has_redirect returns false, readonly filter applies. head is readonly, + # filter drops it, go matches user rule -> allow. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^go(\\s|$)" }, "reason": "allow go" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"go test ./... 2>&1 | head"}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] +} + @test "compound: allow rules covering ALL segments allows compound command" { # Two different rules covering two different segments: both must match # for the compound command to be allowed. Use non-readonly commands (make, From 1cd98f9fcf1f46df96edaa5b4be5149df0244626 Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Sat, 18 Apr 2026 15:49:43 +0800 Subject: [PATCH 3/5] fix(overlay): cross-session lock at user-scope with stale-lock recovery The overlay queue lock was scoped to $TMPDIR, which on macOS is often a per-process folder under /var/folders/... - not shared across CC sessions. Two simultaneous CC sessions could each open their own tmux popup, breaking the serialization. Also: when a hook was SIGKILLed (OOM, timeout) the lock directory persisted forever, blocking every subsequent overlay until manually cleared. Fixes: - Lock lives at $(passthru_user_home)/passthru-overlay.lock.d, guaranteed shared across CC sessions of the same user. - New _OVERLAY_LOCK_STALE_AFTER threshold (default 180s, > overlay timeout + margin): if the existing lock's mtime is older than that, clear it and retry. Checked every ~2s during wait. - Default lock timeout raised from 90s to 180s to match typical user response time across multiple queued sessions. --- hooks/handlers/pre-tool-use.sh | 50 +++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/hooks/handlers/pre-tool-use.sh b/hooks/handlers/pre-tool-use.sh index f8bc798..62a77c3 100755 --- a/hooks/handlers/pre-tool-use.sh +++ b/hooks/handlers/pre-tool-use.sh @@ -801,12 +801,23 @@ fi export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS # --- Overlay queue lock ------------------------------------------------------- -# CC can fire multiple PreToolUse hooks concurrently (parallel tool calls). -# Only one overlay popup can be visible at a time in a given multiplexer. -# Without serialization, the second+ hook falls through to CC's native dialog. -# We use a mkdir-based lock to queue concurrent overlay invocations. -_OVERLAY_LOCK="${_tmpdir}/passthru-overlay.lock.d" -_OVERLAY_LOCK_TIMEOUT="${PASSTHRU_OVERLAY_LOCK_TIMEOUT:-90}" +# CC can fire multiple PreToolUse hooks concurrently, and multiple CC sessions +# on the same machine will also race for the same tmux/kitty/wezterm popup. +# Only one overlay popup can be visible at a time. We serialize via a mkdir +# lock at a user-scope path so the lock is shared across both intra-session +# concurrent calls and cross-session concurrent calls. +# +# The lock MUST live under passthru_user_home, not TMPDIR, because macOS +# gives each process a per-user (and sometimes per-session) TMPDIR under +# /var/folders/... which is NOT shared across CC sessions. +_OVERLAY_LOCK_ROOT="$(passthru_user_home)" +[ -d "$_OVERLAY_LOCK_ROOT" ] || mkdir -p "$_OVERLAY_LOCK_ROOT" 2>/dev/null || true +_OVERLAY_LOCK="${_OVERLAY_LOCK_ROOT}/passthru-overlay.lock.d" +_OVERLAY_LOCK_TIMEOUT="${PASSTHRU_OVERLAY_LOCK_TIMEOUT:-180}" +# If a lock is older than this, treat it as stale (process died without +# releasing). Should be > PASSTHRU_OVERLAY_TIMEOUT (default 60s) plus +# overlay launch + post-processing margin. +_OVERLAY_LOCK_STALE_AFTER="${PASSTHRU_OVERLAY_LOCK_STALE_AFTER:-180}" _overlay_lock_acquired=0 _release_overlay_lock() { @@ -816,8 +827,27 @@ _release_overlay_lock() { fi } -# Acquire the lock. Poll at 200ms intervals up to the timeout. +# Detect and clear stale locks: if the lock directory exists but its mtime +# is older than _OVERLAY_LOCK_STALE_AFTER seconds, a previous hook was +# killed (SIGKILL, OOM, etc.) without running its EXIT trap. Force-clean. +_clear_if_stale() { + if [ -d "$_OVERLAY_LOCK" ]; then + local mtime + mtime="$(stat -f %m "$_OVERLAY_LOCK" 2>/dev/null || stat -c %Y "$_OVERLAY_LOCK" 2>/dev/null || echo 0)" + local now age + now="$(date +%s)" + age=$((now - mtime)) + if [ "$age" -gt "$_OVERLAY_LOCK_STALE_AFTER" ]; then + printf '[passthru] clearing stale overlay lock (age %ds)\n' "$age" >&2 + rm -rf "$_OVERLAY_LOCK" 2>/dev/null || true + fi + fi +} + +# Acquire the lock. Poll at 200ms intervals up to the timeout. Check for +# stale locks every few iterations so a dead hook does not block forever. _lock_start="$(date +%s)" +_stale_check_counter=0 while true; do if mkdir "$_OVERLAY_LOCK" 2>/dev/null; then _overlay_lock_acquired=1 @@ -826,6 +856,12 @@ while true; do trap '_release_overlay_lock' EXIT break fi + # Every 10th iteration (~2s), check for a stale lock. + _stale_check_counter=$((_stale_check_counter + 1)) + if [ "$_stale_check_counter" -ge 10 ]; then + _stale_check_counter=0 + _clear_if_stale + fi _now="$(date +%s)" if [ $((_now - _lock_start)) -ge "$_OVERLAY_LOCK_TIMEOUT" ]; then printf '[passthru] overlay lock timeout after %ds; falling back to native dialog\n' "$_OVERLAY_LOCK_TIMEOUT" >&2 From 1d2572db06c7a969bd8bba1eb93bcbedef6b3f6d Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Sat, 18 Apr 2026 16:43:34 +0800 Subject: [PATCH 4/5] docs(claude.md): cross-session overlay lock, native-dialog cascade, hook ordering - correct stale 75s hook timeout reference (actual is 300s) - new "Overlay queue lock (cross-session)" section: user-home lock path, macOS TMPDIR caveat, stale-lock recovery - new "Interaction with CC's native permission system" section: decision cascade that explains why "ask" triggers native dialog, and how multi-plugin hook ordering causes passthru to see either original or wrapped (rtk) commands - new "Notifications on overlay prompt" section: /dev/tty requirement and tmux DCS passthrough wrapping - document PASSTHRU_OVERLAY_LOCK_TIMEOUT, PASSTHRU_OVERLAY_LOCK_STALE_AFTER, and PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS env vars --- CLAUDE.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 27ff35f..9fe49e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ commands/ log.md /passthru:log slash command (prompt-based) overlay.md /passthru:overlay slash command (wraps scripts/overlay-config.sh) hooks/ - hooks.json registers PreToolUse (timeout 75s, matcher "*"), PostToolUse + + hooks.json registers PreToolUse (timeout 300s, matcher "*"), PostToolUse + PostToolUseFailure (timeout 10s each, matcher "*"), and SessionStart (timeout 5s, no matcher) handlers common.sh shared library. Functions: @@ -118,7 +118,10 @@ Variables the plugin reads at runtime. Most are test-only overrides; a couple (` * `PASSTHRU_OVERLAY_TEST_ANSWER` - short-circuit the interactive keypress loop in `overlay-dialog.sh`. Accepts `yes_once|yes_always|no_once|no_always|cancel`. Used exclusively by `tests/overlay.bats` + `tests/hook_handler.bats` to exercise every branch without pseudo-tty gymnastics. Never set by the hook in production. * `PASSTHRU_OVERLAY_TOOL_NAME` - tool name passed into the overlay dialog. Hook propagates the inbound `tool_name` field verbatim. * `PASSTHRU_OVERLAY_TOOL_INPUT_JSON` - tool input JSON (stringified) passed into the overlay dialog. Hook propagates the inbound `tool_input` field verbatim. The dialog and `overlay-propose-rule.sh` parse it for the suggested-rule screen. -* `PASSTHRU_OVERLAY_TIMEOUT` - seconds to wait for a user response inside the overlay. Default 60. If the user does not respond in time, the overlay exits without writing a verdict and the hook treats the prompt as cancelled (falls through to the native dialog). Setting below 60 is fine; setting above requires also raising the PreToolUse hook timeout (currently 75s). +* `PASSTHRU_OVERLAY_TIMEOUT` - seconds to wait for a user response inside the overlay. Default 60. If the user does not respond in time, the overlay exits without writing a verdict and the hook treats the prompt as cancelled (falls through to the native dialog). Setting below 60 is fine; setting above requires also raising the PreToolUse hook timeout (currently 300s). +* `PASSTHRU_OVERLAY_LOCK_TIMEOUT` - seconds to wait for another CC session's overlay to release the user-scope queue lock. Default 180. On timeout, the hook emits the ask fallback (native dialog). See the "Overlay queue lock" section below. +* `PASSTHRU_OVERLAY_LOCK_STALE_AFTER` - mtime age threshold in seconds after which an existing overlay lock is considered abandoned and auto-cleared. Default 180. Protects against SIGKILLed hooks leaving zombie locks. +* `PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS` - newline-separated list of compound Bash segments that are NOT covered by readonly auto-allow or by any allow/ask rule. Set by `pre-tool-use.sh` before invoking the overlay. Read by `overlay-propose-rule.sh` so that "yes/no always" proposals target only the uncovered portion instead of the full command's first word. * `PASSTHRU_WRITE_LOCK_TIMEOUT` - seconds `scripts/write-rule.sh` and `scripts/remove-rule.sh` wait for the user-scope mkdir lock. Default 5. See the "Write-wrapper locking" section below. ## How tests run @@ -221,22 +224,25 @@ concurrent project shells. `PostToolUse`, `PostToolUseFailure`, and `SessionStart` are registered with short timeouts (10s / 10s / 5s) in `hooks/hooks.json`. `PreToolUse` runs with -a **75s** timeout because Task 8 (v0.5.0) wired the hook to block -synchronously on the interactive terminal-overlay dialog. +a **300s** timeout because the hook blocks synchronously on the interactive +terminal-overlay dialog AND may also queue behind an overlay held by another +CC session on the same machine. -The 75s figure breaks down as: +The 300s figure breaks down as: * The overlay dialog (`scripts/overlay-dialog.sh`) enforces its own 60s budget (`PASSTHRU_OVERLAY_TIMEOUT`, default 60s). -* Add 15s of margin for overlay launch, multiplexer roundtrip, post-dialog +* The overlay queue lock (`PASSTHRU_OVERLAY_LOCK_TIMEOUT`, default 180s) + waits for other sessions' overlays to complete. +* Add margin for overlay launch, multiplexer roundtrip, post-dialog rule write via `write-rule.sh`, and audit line emission. -* CC's hook timeout is wall-clock (confirmed via `time sleep 1`: 1.008s - real). Anything below the overlay's own budget would kill the hook - mid-dialog and lose the user's verdict. +* CC's hook timeout is wall-clock. Anything below the overlay's own budget + plus the lock-wait budget would kill the hook mid-wait and lose the + user's verdict. The 10s baseline for non-overlay PreToolUse paths (rule match, mode auto-allow) still applies in the sense that none of them block on IO; the -75s cap only matters when the overlay is actually invoked. +300s cap only matters when the overlay is actually invoked. For post-event handlers, the original 10s baseline continues to hold: @@ -256,6 +262,89 @@ Lower the PreToolUse timeout only after also lowering `PASSTHRU_OVERLAY_TIMEOUT` (and only after profiling on target hardware). Raising it is always safe since the handler fails open on timeout. +## Overlay queue lock (cross-session) + +The overlay popup is singleton per machine: tmux/kitty/wezterm can only +show one popup at a time. Two CC sessions racing for the overlay would +otherwise both try to open popups and one would fail, falling through to +CC's native dialog. + +`hooks/handlers/pre-tool-use.sh` serializes overlays via a mkdir lock at +`$(passthru_user_home)/passthru-overlay.lock.d`. The lock MUST live under +user home, NOT `$TMPDIR`: on macOS `$TMPDIR` resolves to a per-process +`/var/folders//.../T/` folder that is NOT shared across CC +sessions of the same user. User home is the only guaranteed shared +location. + +Stale-lock recovery runs every ~2s during wait. If the existing lock's +mtime is older than `PASSTHRU_OVERLAY_LOCK_STALE_AFTER` (default 180s), +the lock is force-removed. This prevents a hook that was SIGKILLed +(OOM, manual kill) from blocking every subsequent overlay forever. + +Env knobs: + +* `PASSTHRU_OVERLAY_LOCK_TIMEOUT` (default 180s) - how long to wait for + another session's overlay before falling back to CC's native dialog. +* `PASSTHRU_OVERLAY_LOCK_STALE_AFTER` (default 180s) - mtime age at which + an existing lock is considered abandoned and auto-cleared. + +## Interaction with CC's native permission system + +Passthru is one of potentially several PreToolUse hooks AND sits alongside +CC's built-in permission evaluation. Understanding which decision wins in +which scenario is essential for debugging "why did the native dialog +appear?" complaints. + +**Decision cascade after PreToolUse hooks return:** + +1. If any hook emits `permissionDecision: "allow"` - CC proceeds silently. +2. If any hook emits `permissionDecision: "deny"` - CC blocks the tool. +3. If a hook emits `permissionDecision: "ask"` - CC shows its NATIVE + dialog. This is by design: "ask" explicitly defers to CC's UI. +4. If all hooks pass through (`{"continue": true}`) - CC evaluates its own + `permissions.allow` entries from `settings.json`. If none match, CC + shows its native dialog. + +Implication: passthru emitting `ask` (either explicitly or via overlay +fall-through / lock timeout) will trigger a native dialog. Only `allow` +fully suppresses it. This is why the compound readonly-filter fix (`go +test | tail` now resolves to allow instead of ask) eliminates the native +dialog cascade. + +**Multi-plugin hook ordering:** + +CC runs PreToolUse hooks in plugin registration order. Each subsequent +hook sees `tool_input` as MODIFIED by previous hooks. Plugins like `rtk` +(which rewrites `go test` to `rtk go test`) can either run before or +after passthru depending on ordering: + +* rtk BEFORE passthru: passthru sees `rtk go test ...`. User rule for + `^go` does not match. Falls through to overlay. +* rtk AFTER passthru: passthru sees `go test ...`. User rule matches, + decision is "allow". CC then runs rtk which rewrites the command, CC + executes the rewritten command. + +If the user reports seeing the overlay for BOTH `go ...` and `rtk go ...` +variants intermittently, hook ordering is non-deterministic or multiple +rtk code paths (proxy vs rewrite) are in play. Rule coverage should +anticipate both forms or use a broader pattern. + +## Notifications on overlay prompt + +`pre-tool-use.sh` sends an OSC 777 desktop notification before invoking +the overlay so the user knows a prompt is waiting. Two gotchas: + +* Must write to `/dev/tty`, NOT stdout. Stdout is captured by CC as the + hook's JSON response and the OSC sequence would pollute (or invalidate) + the JSON payload. +* Inside tmux, the OSC must be wrapped in DCS passthrough: `ESC P tmux; + ESC \` with every inner `ESC` doubled. Additionally tmux needs + `set -g allow-passthrough on` in the user's tmux.conf. Without + passthrough, tmux strips the OSC and Ghostty/iTerm2 never sees it. + +OSC 777 format: `ESC ] 777 ; notify ; ; <body> BEL`. Supported +by Ghostty, iTerm2, Konsole, and most modern terminal emulators. + ## Compound command splitting For Bash tool calls, the hook splits compound commands into segments before From ad09fec7028b9e5d7f41fdcad8b35110b6d03b3d Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky <vaze.legend@gmail.com> Date: Sat, 18 Apr 2026 16:48:16 +0800 Subject: [PATCH 5/5] chore(release): v0.7.0 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9271ecf..1ba26c0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "passthru", - "version": "0.6.0", + "version": "0.7.0", "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 5841817..07242f6 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "passthru", - "version": "0.6.0", + "version": "0.7.0", "description": "Regex-based permission rules for Claude Code via hooks", "license": "MIT" }