Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
109 changes: 99 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand All @@ -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/<session-id>/.../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;
<inner> 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 ; <title> ; <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
Expand Down
66 changes: 66 additions & 0 deletions hooks/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2186,3 +2186,69 @@ match_all_segments() {

return 0
}

# ---------------------------------------------------------------------------
# compute_unallowed_segments
# ---------------------------------------------------------------------------
#
# Usage: compute_unallowed_segments <ordered_entries_json> <tool_name> \
# <cwd> <allowed_dirs_json> <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
}
117 changes: 107 additions & 10 deletions hooks/handlers/pre-tool-use.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -751,13 +783,41 @@ 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.
# 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() {
Expand All @@ -767,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
Expand All @@ -777,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
Expand All @@ -787,7 +872,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 <escaped-inner> 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
Expand Down
Loading
Loading