Skip to content

Commit e6e4cb1

Browse files
committed
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
1 parent 5beee3d commit e6e4cb1

6 files changed

Lines changed: 263 additions & 23 deletions

File tree

hooks/common.sh

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2186,3 +2186,69 @@ match_all_segments() {
21862186

21872187
return 0
21882188
}
2189+
2190+
# ---------------------------------------------------------------------------
2191+
# compute_unallowed_segments
2192+
# ---------------------------------------------------------------------------
2193+
#
2194+
# Usage: compute_unallowed_segments <ordered_entries_json> <tool_name> \
2195+
# <cwd> <allowed_dirs_json> <segments...>
2196+
#
2197+
# For compound Bash commands that fall through to the overlay, identifies
2198+
# which segments are NOT covered by readonly auto-allow or by any allow/ask
2199+
# rule. The overlay uses this information to propose a rule targeting only
2200+
# the uncovered portion instead of the full command's first word.
2201+
#
2202+
# A segment is "unallowed" if:
2203+
# - It is NOT read-only auto-allowed (either not in readonly list or
2204+
# has absolute path outside cwd/allowed_dirs).
2205+
# - AND it does not match any allow or ask rule in ordered_entries.
2206+
#
2207+
# Output (stdout): NUL-separated list of unallowed segment strings.
2208+
# Return: 0 always.
2209+
compute_unallowed_segments() {
2210+
local ordered="$1"
2211+
local tool_name="$2"
2212+
local cwd="$3"
2213+
local allowed_dirs_json="$4"
2214+
shift 4
2215+
2216+
local _cus_segments=("$@")
2217+
local seg_count="${#_cus_segments[@]}"
2218+
[ "$seg_count" -eq 0 ] && return 0
2219+
2220+
local ordered_count
2221+
ordered_count="$(jq -r 'if type == "array" then length else 0 end' <<<"$ordered" 2>/dev/null)"
2222+
[ -z "$ordered_count" ] && ordered_count=0
2223+
2224+
local seg seg_idx seg_input entry rule mrc matched
2225+
for ((seg_idx = 0; seg_idx < seg_count; seg_idx++)); do
2226+
seg="${_cus_segments[$seg_idx]}"
2227+
2228+
# Skip read-only segments with valid paths.
2229+
if is_readonly_command "$seg" \
2230+
&& readonly_paths_allowed "$seg" "$cwd" "$allowed_dirs_json"; then
2231+
continue
2232+
fi
2233+
2234+
# Check if it matches any allow/ask rule.
2235+
seg_input="$(jq -cn --arg c "$seg" '{command: $c}')"
2236+
matched=0
2237+
local i
2238+
for ((i = 0; i < ordered_count; i++)); do
2239+
entry="$(jq -c --argjson i "$i" '.[$i]' <<<"$ordered" 2>/dev/null)"
2240+
rule="$(jq -c '.rule // {}' <<<"$entry" 2>/dev/null)"
2241+
mrc=0
2242+
match_rule "$tool_name" "$seg_input" "$rule" || mrc=$?
2243+
if [ "$mrc" -eq 0 ]; then
2244+
matched=1
2245+
break
2246+
fi
2247+
done
2248+
2249+
if [ "$matched" -eq 0 ]; then
2250+
printf '%s\0' "$seg"
2251+
fi
2252+
done
2253+
return 0
2254+
}

hooks/handlers/pre-tool-use.sh

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,23 @@ export PASSTHRU_OVERLAY_TOOL_NAME="$TOOL_NAME"
751751
export PASSTHRU_OVERLAY_TOOL_INPUT_JSON="$TOOL_INPUT"
752752
export PASSTHRU_OVERLAY_CWD="$CC_CWD"
753753

754+
# For compound Bash commands falling through to overlay, compute which
755+
# segments are uncovered (not readonly auto-allowed, not matched by any
756+
# allow/ask rule). The overlay uses this to propose a regex targeting only
757+
# the uncovered portion, not the full command's first word. Separator is
758+
# newline since env vars cannot carry NULs portably across multiplexer
759+
# popups. Segments never contain newlines because split_bash_command splits
760+
# at operators and redirections.
761+
PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS=""
762+
if [ "$TOOL_NAME" = "Bash" ] && [ "$BASH_SEGMENT_COUNT" -gt 1 ]; then
763+
_unallowed_raw="$(compute_unallowed_segments "$ORDERED" "$TOOL_NAME" "$CC_CWD" "$ALLOWED_DIRS_JSON" "${BASH_SEGMENTS[@]}" 2>/dev/null || true)"
764+
# Convert NUL separators to newlines for env var transport.
765+
if [ -n "$_unallowed_raw" ]; then
766+
PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS="$(printf '%s' "$_unallowed_raw" | tr '\0' '\n')"
767+
fi
768+
fi
769+
export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS
770+
754771
# --- Overlay queue lock -------------------------------------------------------
755772
# CC can fire multiple PreToolUse hooks concurrently (parallel tool calls).
756773
# Only one overlay popup can be visible at a time in a given multiplexer.
@@ -787,7 +804,19 @@ done
787804

788805
# Send a desktop notification so the user knows a permission prompt is waiting.
789806
# OSC 777 is supported by Ghostty, iTerm2, and other modern terminals.
790-
printf '\033]777;notify;passthru;permission prompt: %s\a' "$TOOL_NAME" 2>/dev/null || true
807+
# Write to /dev/tty, not stdout - stdout is captured by CC as the hook's JSON
808+
# response. When running inside tmux, the OSC sequence must be wrapped in
809+
# tmux's "passthrough" escape (DCS tmux; ... ST) to reach the outer terminal.
810+
_notify_msg="passthru: permission prompt: ${TOOL_NAME}"
811+
if [ -e /dev/tty ]; then
812+
if [ -n "${TMUX:-}" ]; then
813+
# tmux wraps: ESC P tmux; ESC <escaped-inner> ESC \
814+
# Inner ESC characters must be doubled (ESC ESC) for tmux passthrough.
815+
printf '\033Ptmux;\033\033]777;notify;passthru;%s\a\033\\' "$_notify_msg" > /dev/tty 2>/dev/null || true
816+
else
817+
printf '\033]777;notify;passthru;%s\a' "$_notify_msg" > /dev/tty 2>/dev/null || true
818+
fi
819+
fi
791820

792821
# Invoke the overlay and capture its exit code. We have an ERR trap in place
793822
# (converts unexpected errors to fail-open passthrough), so we cannot rely on

scripts/overlay-dialog.sh

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -184,13 +184,66 @@ selected=0
184184
# Build a human-readable preview from tool_input. Each tool type gets a
185185
# tailored display. MCP tools show pretty JSON. Edits show a diff preview.
186186
_extract() { jq -r --arg f "$1" '.[$f] // empty' <<<"$TOOL_INPUT_JSON" 2>/dev/null; }
187-
_truncate() {
188-
local s="$1" max="${2:-120}"
189-
if [ "${#s}" -gt "$max" ]; then
190-
printf '%s...' "${s:0:$((max - 3))}"
191-
else
192-
printf '%s' "$s"
187+
188+
# _wrap_line: split a string into multiple lines at whitespace boundaries,
189+
# each line fitting within the given width. Falls back to hard-splitting
190+
# for any single run of non-whitespace longer than the width. Emits lines
191+
# via printf '%s\n' so callers can read them with `while IFS= read`.
192+
_wrap_line() {
193+
local s="$1" width="${2:-100}"
194+
# Hard guard against tiny widths or empty input.
195+
[ "$width" -lt 20 ] && width=20
196+
if [ -z "$s" ] || [ "${#s}" -le "$width" ]; then
197+
printf '%s\n' "$s"
198+
return 0
193199
fi
200+
awk -v w="$width" '
201+
{
202+
line = ""
203+
n = split($0, words, /[ \t]+/)
204+
for (i = 1; i <= n; i++) {
205+
word = words[i]
206+
if (word == "") continue
207+
if (length(line) == 0) {
208+
# First word on a line. If it itself exceeds width, hard-split it.
209+
while (length(word) > w) {
210+
print substr(word, 1, w)
211+
word = substr(word, w + 1)
212+
}
213+
line = word
214+
} else if (length(line) + 1 + length(word) <= w) {
215+
line = line " " word
216+
} else {
217+
print line
218+
while (length(word) > w) {
219+
print substr(word, 1, w)
220+
word = substr(word, w + 1)
221+
}
222+
line = word
223+
}
224+
}
225+
if (length(line) > 0) print line
226+
}
227+
' <<<"$s"
228+
}
229+
230+
# _append_wrapped: push wrapped lines of $1 into preview_lines, updating
231+
# extra_height accordingly. Width defaults to terminal columns - 10 (for
232+
# "Input: " prefix and popup padding) or 100 if tput fails.
233+
_append_wrapped() {
234+
local s="$1"
235+
local cols
236+
cols="$(tput cols 2>/dev/null || echo 0)"
237+
[ "$cols" -lt 40 ] && cols=110
238+
local width=$((cols - 10))
239+
local first=1
240+
while IFS= read -r _wl; do
241+
preview_lines+=("$_wl")
242+
if [ "$first" -eq 0 ]; then
243+
extra_height=$((extra_height + 1))
244+
fi
245+
first=0
246+
done < <(_wrap_line "$s" "$width")
194247
}
195248

196249
# 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
200253
if [ -n "$TOOL_INPUT_JSON" ]; then
201254
case "$TOOL_NAME" in
202255
Bash)
203-
preview_lines+=("$(_truncate "$(_extract command)" 120)")
256+
_append_wrapped "$(_extract command)"
204257
;;
205258
WebFetch)
206-
preview_lines+=("$(_extract url)")
259+
_append_wrapped "$(_extract url)"
207260
;;
208261
WebSearch)
209262
_q="$(_extract query)"
210-
[ -n "$_q" ] && preview_lines+=("search: $_q") || preview_lines+=("$(_extract url)")
263+
if [ -n "$_q" ]; then
264+
_append_wrapped "search: $_q"
265+
else
266+
_append_wrapped "$(_extract url)"
267+
fi
211268
;;
212269
Edit|Write)
213-
preview_lines+=("$(_extract file_path)")
270+
_append_wrapped "$(_extract file_path)"
214271
;;
215272
Read|NotebookRead)
216-
preview_lines+=("$(_extract file_path)")
273+
_append_wrapped "$(_extract file_path)"
217274
;;
218275
NotebookEdit)
219276
_fp="$(_extract file_path)"
220277
_cell="$(_extract cell_id)"
221-
preview_lines+=("$_fp")
222-
[ -n "$_cell" ] && preview_lines+=("cell: $_cell") && extra_height=1
278+
_append_wrapped "$_fp"
279+
if [ -n "$_cell" ]; then
280+
preview_lines+=("cell: $_cell")
281+
extra_height=$((extra_height + 1))
282+
fi
223283
;;
224284
Grep)
225285
_pat="$(_extract pattern)"
226286
_path="$(_extract path)"
227-
preview_lines+=("/$_pat/")
228-
[ -n "$_path" ] && preview_lines+=("in: $_path") && extra_height=1
287+
_append_wrapped "/$_pat/"
288+
if [ -n "$_path" ]; then
289+
preview_lines+=("in: $_path")
290+
extra_height=$((extra_height + 1))
291+
fi
229292
;;
230293
Glob)
231294
_pat="$(_extract pattern)"
232295
_path="$(_extract path)"
233-
preview_lines+=("$_pat")
234-
[ -n "$_path" ] && preview_lines+=("in: $_path") && extra_height=1
296+
_append_wrapped "$_pat"
297+
if [ -n "$_path" ]; then
298+
preview_lines+=("in: $_path")
299+
extra_height=$((extra_height + 1))
300+
fi
235301
;;
236302
Skill)
237303
_skill="$(_extract skill)"
238304
_args="$(_extract args)"
239305
if [ -n "$_args" ]; then
240-
preview_lines+=("$_skill $_args")
306+
_append_wrapped "$_skill $_args"
241307
else
242-
preview_lines+=("$_skill")
308+
_append_wrapped "$_skill"
243309
fi
244310
;;
245311
Agent)
246312
_desc="$(_extract description)"
247-
[ -n "$_desc" ] && preview_lines+=("$_desc") || preview_lines+=("$(_truncate "$(_extract prompt)" 120)")
313+
if [ -n "$_desc" ]; then
314+
_append_wrapped "$_desc"
315+
else
316+
_append_wrapped "$(_extract prompt)"
317+
fi
248318
;;
249319
mcp__*)
250320
# MCP tools: pretty-print the JSON args with indentation.
@@ -266,13 +336,13 @@ if [ -n "$TOOL_INPUT_JSON" ]; then
266336
extra_height=$((_line_count - 1))
267337
;;
268338
*)
269-
preview_lines+=("$(_truncate "$TOOL_INPUT_JSON" 120)")
339+
_append_wrapped "$TOOL_INPUT_JSON"
270340
;;
271341
esac
272342
fi
273343
# Fallback if nothing was extracted.
274344
if [ "${#preview_lines[@]}" -eq 0 ]; then
275-
preview_lines+=("$(_truncate "$TOOL_INPUT_JSON" 120)")
345+
_append_wrapped "$TOOL_INPUT_JSON"
276346
fi
277347

278348
# Session context for the header (helps distinguish multiple CC sessions).

scripts/overlay-propose-rule.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ if [ "$TOOL_NAME" = "Bash" ]; then
8585
emit_fallback
8686
exit 0
8787
fi
88+
# If the hook identified specific uncovered segments (compound command
89+
# where some parts are already auto-allowed), target the first uncovered
90+
# segment instead of the whole command. This produces a useful rule for
91+
# the part that actually needs it.
92+
if [ -n "${PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS:-}" ]; then
93+
# Take the first non-empty line.
94+
cmd="$(printf '%s\n' "$PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS" | awk 'NF{print; exit}')"
95+
fi
8896
# First token = first word of the command. Trim leading whitespace then
8997
# split on whitespace.
9098
cmd="${cmd#"${cmd%%[![:space:]]*}"}"

tests/common_load.bats

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,3 +466,39 @@ EOF
466466
run validate_rules "$merged"
467467
[ "$status" -eq 0 ]
468468
}
469+
470+
# ---------------------------------------------------------------------------
471+
# compute_unallowed_segments
472+
# ---------------------------------------------------------------------------
473+
474+
@test "compute_unallowed_segments: readonly segment is filtered out" {
475+
# Two segments: ls (readonly) and weird_cmd (not readonly, no rule).
476+
# Only weird_cmd should be returned.
477+
result="$(compute_unallowed_segments '[]' "Bash" "$PROJ_ROOT" "[]" "ls" "weird_cmd --flag" | tr '\0' '\n')"
478+
[ "$result" = "weird_cmd --flag" ]
479+
}
480+
481+
@test "compute_unallowed_segments: allow-rule segment is filtered out" {
482+
# Ordered allow/ask list covers 'git' but not 'weird_cmd'.
483+
ordered='[{"list":"allow","merged_idx":0,"rule":{"tool":"Bash","match":{"command":"^git\\b"}}}]'
484+
result="$(compute_unallowed_segments "$ordered" "Bash" "$PROJ_ROOT" "[]" "git status" "weird_cmd" | tr '\0' '\n')"
485+
[ "$result" = "weird_cmd" ]
486+
}
487+
488+
@test "compute_unallowed_segments: all readonly returns empty" {
489+
# All segments readonly -> returns empty.
490+
result="$(compute_unallowed_segments '[]' "Bash" "$PROJ_ROOT" "[]" "ls" "cat README.md" | tr '\0' '\n')"
491+
[ -z "$result" ]
492+
}
493+
494+
@test "compute_unallowed_segments: no rules and no readonly returns all" {
495+
# Nothing covers the segments -> all are unallowed.
496+
result="$(compute_unallowed_segments '[]' "Bash" "$PROJ_ROOT" "[]" "weird_a" "weird_b" | tr '\0' '\n')"
497+
[ "$result" = "$(printf 'weird_a\nweird_b')" ]
498+
}
499+
500+
@test "compute_unallowed_segments: readonly with invalid path is not filtered" {
501+
# cat with absolute path outside cwd -> not readonly-allowed -> uncovered.
502+
result="$(compute_unallowed_segments '[]' "Bash" "$PROJ_ROOT" "[]" "cat /etc/passwd" | tr '\0' '\n')"
503+
[ "$result" = "cat /etc/passwd" ]
504+
}

tests/overlay.bats

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,37 @@ restricted_path() {
282282
[ "$output" = '^rm(\s[^<>()\$\x60|{}&;\n\r]*)?$' ]
283283
}
284284

285+
@test "propose-rule: Bash uses unallowed segments env var when set" {
286+
# Compound command where ls is auto-allowed but weird_cmd is not.
287+
# The proposer should target weird_cmd, not ls.
288+
export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS="weird_cmd --flag"
289+
run bash "$PROPOSER" "Bash" '{"command":"ls && weird_cmd --flag"}'
290+
unset PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS
291+
[ "$status" -eq 0 ]
292+
run jq -r '.match.command' <<<"$output"
293+
[ "$output" = '^weird_cmd(\s[^<>()\$\x60|{}&;\n\r]*)?$' ]
294+
}
295+
296+
@test "propose-rule: Bash uses first non-empty unallowed segment" {
297+
# Multiple unallowed segments; should use the first.
298+
export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS=$'npm test\ncargo build'
299+
run bash "$PROPOSER" "Bash" '{"command":"ls && npm test && cargo build"}'
300+
unset PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS
301+
[ "$status" -eq 0 ]
302+
run jq -r '.match.command' <<<"$output"
303+
[ "$output" = '^npm(\s[^<>()\$\x60|{}&;\n\r]*)?$' ]
304+
}
305+
306+
@test "propose-rule: Bash ignores empty unallowed segments env var" {
307+
# Empty env var should fall back to using the whole command's first word.
308+
export PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS=""
309+
run bash "$PROPOSER" "Bash" '{"command":"git status"}'
310+
unset PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS
311+
[ "$status" -eq 0 ]
312+
run jq -r '.match.command' <<<"$output"
313+
[ "$output" = '^git(\s[^<>()\$\x60|{}&;\n\r]*)?$' ]
314+
}
315+
285316
@test "propose-rule: Read file_path -> parent-dir prefix match" {
286317
run bash "$PROPOSER" "Read" '{"file_path":"/Users/me/proj/src/file.ts"}'
287318
[ "$status" -eq 0 ]

0 commit comments

Comments
 (0)