Skip to content

Commit b668992

Browse files
authored
fix(list): wrap long regex cells instead of truncating (#14)
* fix(list): wrap long regex cells instead of truncating The /passthru:list table used to truncate the match-summary column at 40 (flat) or 50 (grouped) characters with a "..." ellipsis, hiding the trailing part of long regexes. Users shown only half of a regex cannot validate what rule they're about to keep or remove. This commit removes the truncate_str helper, detects terminal width (COLUMNS, tput cols, 120 fallback), allocates column budgets dynamically (tool min 12 / max 20 chars, match 60% of remainder, reason 40%), and wraps overflow across continuation lines. Continuation lines pad the leading columns (scope/list/source/#/tool) with spaces so wrapped text remains aligned under its column header. Breaks prefer space / pipe / comma within the last quarter of the column width, with a hard break fallback. JSON and raw output formats are unchanged. * fix(list): quote dynamic array expansion to satisfy shellcheck SC1087 complained about `${#$arr_name[@]}` inside eval because it saw the bare variable without braces as an array index. Wrap the name in braces so the resolved form is unambiguous. * chore: tidy comments
1 parent 659596e commit b668992

8 files changed

Lines changed: 321 additions & 45 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.4.2",
3+
"version": "0.4.3",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.4.2",
3+
"version": "0.4.3",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"license": "MIT"
66
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ bash .../scripts/bootstrap.sh --write
119119

120120
Regex metacharacters in the original path/prefix/name are escaped so the converted pattern matches literally. Anything that does not match one of the shapes above is skipped with a `[WARN]` line on stderr (for example, custom MCP tool patterns that do not start with `mcp__`, or a `WebFetch(...)` with a non-`domain:` argument).
121121

122-
For `Read`, `Edit`, and `Write`, path acceptance mirrors Claude Code's own rules (`src/utils/permissions/pathValidation.ts`): redundant slash runs (`//foo`, `///foo/bar`) are collapsed to a single slash, `~/...` expands to `$HOME/...`, and paths with spaces or deep nesting are accepted. Only the shapes Claude Code itself rejects are skipped with a `[WARN]`:
122+
For `Read`, `Edit`, and `Write`, path acceptance is permissive: redundant slash runs (`//foo`, `///foo/bar`) are collapsed to a single slash, `~/...` expands to `$HOME/...`, and paths with spaces or deep nesting are accepted. Only clearly invalid shapes are skipped with a `[WARN]`:
123123

124124
* shell / env expansion: `$VAR`, `${VAR}`, `$(cmd)`, `%VAR%`
125125
* zsh equals expansion: leading `=` (e.g. `=cmd`)

scripts/bootstrap.sh

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
#
2929
# Read/Edit/Write paths that use shell expansion (`$VAR`, `${VAR}`, `$(cmd)`,
3030
# `%VAR%`), zsh equals expansion (`=cmd`), tilde variants other than `~/`
31-
# (`~user`, `~+`, `~-`), or UNC (`\\server\share`) are skipped. This mirrors
32-
# Claude Code's own path validation rules.
31+
# (`~user`, `~+`, `~-`), or UNC (`\\server\share`) are skipped.
32+
# Permissive path validation (accept shapes the upstream host accepts).
3333
#
3434
# Flags:
3535
# --write actually write; default is a dry-run that prints JSON to stdout.
@@ -272,9 +272,8 @@ convert_rule() {
272272
# means "anything under this directory"; without it the path is an
273273
# exact-file match.
274274
#
275-
# Acceptance mirrors Claude Code's actual path validation
276-
# (src/utils/permissions/pathValidation.ts). Only the shapes that Claude
277-
# Code itself rejects are skipped here:
275+
# Only clearly invalid path shapes are skipped (empty, bare relative).
276+
# The following forms are rejected here:
278277
# * Unix shell expansion: `$VAR`, `${VAR}`, `$(cmd)`
279278
# * Windows env expansion: `%VAR%`
280279
# * Zsh equals expansion: leading `=`

scripts/list.sh

Lines changed: 225 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -280,16 +280,6 @@ color_for_list() {
280280
# Formatting helpers
281281
# ---------------------------------------------------------------------------
282282

283-
truncate_str() {
284-
local s="$1" max="$2"
285-
local len=${#s}
286-
if [ "$len" -le "$max" ]; then
287-
printf '%s' "$s"
288-
else
289-
printf '%s...' "${s:0:max-3}"
290-
fi
291-
}
292-
293283
# match_summary <rule-json>: "key1: pat1, key2: pat2" or "-".
294284
match_summary() {
295285
local rule="$1"
@@ -314,47 +304,204 @@ rule_tool() {
314304
jq -r '.tool // ""' <<<"$rule"
315305
}
316306

307+
# Detect terminal width. Honors COLUMNS override (tests can set it).
308+
# Falls back to `tput cols`, then 120.
309+
term_width() {
310+
if [ -n "${COLUMNS:-}" ] && [ "${COLUMNS}" -gt 0 ] 2>/dev/null; then
311+
printf '%s' "$COLUMNS"
312+
return
313+
fi
314+
local w=""
315+
if command -v tput >/dev/null 2>&1; then
316+
w="$(tput cols 2>/dev/null || true)"
317+
fi
318+
if [ -n "$w" ] && [ "$w" -gt 0 ] 2>/dev/null; then
319+
printf '%s' "$w"
320+
else
321+
printf '120'
322+
fi
323+
}
324+
325+
# wrap_text <text> <width> [<out-var-array-name>]
326+
# Populates the named bash array with one element per wrapped line.
327+
# Empty input yields a single empty line (so every row still renders).
328+
# Prefers break points at space / `|` / `,` within the last quarter of <width>.
329+
wrap_text() {
330+
local text="$1" width="$2" arr_name="${3:-__wrap_out}"
331+
# Reset the output array to empty.
332+
eval "$arr_name=()"
333+
if [ "$width" -le 0 ]; then
334+
eval "$arr_name+=(\"\$text\")"
335+
return
336+
fi
337+
local remaining="$text"
338+
while [ -n "$remaining" ]; do
339+
local len=${#remaining}
340+
if [ "$len" -le "$width" ]; then
341+
eval "$arr_name+=(\"\$remaining\")"
342+
break
343+
fi
344+
# Hunt for a friendly break within the last quarter of the width.
345+
local lookback=$(( width / 4 ))
346+
[ "$lookback" -lt 1 ] && lookback=1
347+
local low=$(( width - lookback ))
348+
[ "$low" -lt 1 ] && low=1
349+
local break_pos=-1
350+
local ch i
351+
for (( i = width - 1; i >= low; i-- )); do
352+
ch="${remaining:$i:1}"
353+
case "$ch" in
354+
' '|'|'|',') break_pos=$i; break ;;
355+
esac
356+
done
357+
local chunk rest_start
358+
if [ "$break_pos" -ge 0 ]; then
359+
# Keep the friendly break character on the current chunk so visual
360+
# continuity is preserved (e.g. the trailing ",").
361+
chunk="${remaining:0:break_pos+1}"
362+
rest_start=$(( break_pos + 1 ))
363+
# Trim leading whitespace from the continuation so indentation is not
364+
# doubled-up.
365+
while [ "$rest_start" -lt "$len" ] && [ "${remaining:$rest_start:1}" = " " ]; do
366+
rest_start=$(( rest_start + 1 ))
367+
done
368+
else
369+
chunk="${remaining:0:$width}"
370+
rest_start=$width
371+
fi
372+
eval "$arr_name+=(\"\$chunk\")"
373+
remaining="${remaining:$rest_start}"
374+
done
375+
# Text was originally empty; emit one empty cell.
376+
local __wrap_len
377+
# Use braces around the variable name so shellcheck sees this as a variable,
378+
# not an array index expansion: ${name}[@] -> ${<resolved>}[@] -> array ref.
379+
eval "__wrap_len=\${#${arr_name}[@]}"
380+
if [ "$__wrap_len" -eq 0 ]; then
381+
eval "$arr_name+=('')"
382+
fi
383+
}
384+
317385
# ---------------------------------------------------------------------------
318386
# Renderers
319387
# ---------------------------------------------------------------------------
320388

389+
# Column layout for the flat renderer:
390+
# scope(8) list(6) source(9) #(4) tool(W_tool) match-summary(W_match) reason(rest)
391+
# Fixed columns sum: 8+1+6+1+9+1+4+2 = 32 chars (with single-space gaps and
392+
# the double-space after #). Then tool, match, reason share the rest.
321393
render_flat_table() {
322394
local use_color=0
323395
tty_color && use_color=1
324396
local reset=""
325397
[ "$use_color" -eq 1 ] && reset='\033[0m'
326-
printf '%-8s %-6s %-9s %4s %-18s %-40s %s\n' \
327-
'scope' 'list' 'source' '#' 'tool' 'match-summary' 'reason'
328-
printf '%s\n' '------------------------------------------------------------------------------------------------------'
329-
local n i entry scope list source idx tool match_sum reason color
398+
399+
local total
400+
total="$(term_width)"
401+
402+
# Fixed lead columns consume this many chars (see above).
403+
local fixed=32
404+
405+
local n i entry scope list source idx tool match_sum reason
330406
n="$(jq -r 'length' <<<"$ANNOTATED")"
407+
408+
# First pass: widest tool.
409+
local widest_tool=4 t_len
410+
for ((i = 0; i < n; i++)); do
411+
entry="$(jq -c ".[${i}]" <<<"$ANNOTATED")"
412+
tool="$(rule_tool "$(jq -c '.rule' <<<"$entry")")"
413+
[ -z "$tool" ] && tool="-"
414+
t_len=${#tool}
415+
[ "$t_len" -gt "$widest_tool" ] && widest_tool="$t_len"
416+
done
417+
local W_tool=$widest_tool
418+
[ "$W_tool" -lt 12 ] && W_tool=12
419+
[ "$W_tool" -gt 20 ] && W_tool=20
420+
421+
# Remaining budget for match + reason.
422+
local remaining=$(( total - fixed - W_tool - 1 ))
423+
[ "$remaining" -lt 50 ] && remaining=50
424+
local W_match=$(( remaining * 60 / 100 ))
425+
local W_reason=$(( remaining - W_match - 1 ))
426+
[ "$W_match" -lt 30 ] && W_match=30
427+
[ "$W_reason" -lt 20 ] && W_reason=20
428+
429+
local fmt_hdr="%-8s %-6s %-9s %4s %-${W_tool}s %-${W_match}s %s"
430+
# shellcheck disable=SC2059
431+
printf "$fmt_hdr\n" 'scope' 'list' 'source' '#' 'tool' 'match-summary' 'reason'
432+
433+
local dash_total=$(( fixed + W_tool + 1 + W_match + 1 + W_reason ))
434+
local dashes=""
435+
local d
436+
for ((d = 0; d < dash_total; d++)); do dashes="${dashes}-"; done
437+
printf '%s\n' "$dashes"
438+
439+
local match_lines=() reason_lines=()
440+
local max_lines li msum_line reason_line color pad_match pad_tool
331441
for ((i = 0; i < n; i++)); do
332442
entry="$(jq -c ".[${i}]" <<<"$ANNOTATED")"
333443
scope="$(jq -r '.scope' <<<"$entry")"
334444
list="$(jq -r '.list' <<<"$entry")"
335445
source="$(jq -r '.source' <<<"$entry")"
336446
idx="$(jq -r '.index' <<<"$entry")"
337-
tool="$(truncate_str "$(rule_tool "$(jq -c '.rule' <<<"$entry")")" 18)"
447+
tool="$(rule_tool "$(jq -c '.rule' <<<"$entry")")"
338448
[ -z "$tool" ] && tool="-"
339-
match_sum="$(truncate_str "$(match_summary "$(jq -c '.rule' <<<"$entry")")" 40)"
449+
match_sum="$(match_summary "$(jq -c '.rule' <<<"$entry")")"
340450
reason="$(rule_reason "$(jq -c '.rule' <<<"$entry")")"
451+
452+
wrap_text "$match_sum" "$W_match" match_lines
453+
wrap_text "$reason" "$W_reason" reason_lines
454+
455+
max_lines=${#match_lines[@]}
456+
[ "${#reason_lines[@]}" -gt "$max_lines" ] && max_lines=${#reason_lines[@]}
457+
341458
if [ "$use_color" -eq 1 ]; then
342459
color="$(color_for_list "$list")"
343-
printf "${color}%-8s %-6s %-9s %4s %-18s %-40s %s${reset}\n" \
344-
"$scope" "$list" "$source" "$idx" "$tool" "$match_sum" "$reason"
345460
else
346-
printf '%-8s %-6s %-9s %4s %-18s %-40s %s\n' \
347-
"$scope" "$list" "$source" "$idx" "$tool" "$match_sum" "$reason"
461+
color=""
348462
fi
463+
464+
# Wrap tool only on the first line; padding on continuations.
465+
printf -v pad_tool '%*s' "$W_tool" ''
466+
467+
for ((li = 0; li < max_lines; li++)); do
468+
msum_line="${match_lines[$li]:-}"
469+
reason_line="${reason_lines[$li]:-}"
470+
if [ "$li" -eq 0 ]; then
471+
if [ "$use_color" -eq 1 ]; then
472+
printf "${color}%-8s %-6s %-9s %4s %-${W_tool}s %-${W_match}s %s${reset}\n" \
473+
"$scope" "$list" "$source" "$idx" "$tool" "$msum_line" "$reason_line"
474+
else
475+
printf "%-8s %-6s %-9s %4s %-${W_tool}s %-${W_match}s %s\n" \
476+
"$scope" "$list" "$source" "$idx" "$tool" "$msum_line" "$reason_line"
477+
fi
478+
else
479+
# Continuation line: pad scope/list/source/#/tool with spaces so the
480+
# wrapped tail sits under its column.
481+
if [ "$use_color" -eq 1 ]; then
482+
printf "${color}%-8s %-6s %-9s %4s %s %-${W_match}s %s${reset}\n" \
483+
'' '' '' '' "$pad_tool" "$msum_line" "$reason_line"
484+
else
485+
printf "%-8s %-6s %-9s %4s %s %-${W_match}s %s\n" \
486+
'' '' '' '' "$pad_tool" "$msum_line" "$reason_line"
487+
fi
488+
fi
489+
done
349490
done
350491
}
351492

493+
# Column layout for the grouped renderer:
494+
# #(4) tool(W_tool) match-summary(W_match) reason(rest)
495+
# Lead columns (before match) consume 4 + 2 + W_tool + 1 chars.
352496
render_grouped_table() {
353497
local use_color=0
354498
tty_color && use_color=1
355499
local reset=""
356500
[ "$use_color" -eq 1 ] && reset='\033[0m'
357501

502+
local total
503+
total="$(term_width)"
504+
358505
# Collect unique (scope, list, source) group keys in first-seen order.
359506
# Using jq to compute a stable set of tuples preserving the ANNOTATED order.
360507
local groups
@@ -402,24 +549,73 @@ render_grouped_table() {
402549
"$(printf '%s' "$scope" | tr '[:lower:]' '[:upper:]')" \
403550
"$list" "$source" "$mcount"
404551

405-
printf '%4s %-18s %-50s %s\n' '#' 'tool' 'match-summary' 'reason'
406-
407-
local mi mentry midx mtool msum mreason
552+
# Per-group column budget.
553+
local widest_tool=4 t_len mi mentry mtool
554+
for ((mi = 0; mi < mcount; mi++)); do
555+
mentry="$(jq -c ".[${mi}]" <<<"$members")"
556+
mtool="$(rule_tool "$(jq -c '.rule' <<<"$mentry")")"
557+
[ -z "$mtool" ] && mtool="-"
558+
t_len=${#mtool}
559+
[ "$t_len" -gt "$widest_tool" ] && widest_tool="$t_len"
560+
done
561+
local W_tool=$widest_tool
562+
[ "$W_tool" -lt 12 ] && W_tool=12
563+
[ "$W_tool" -gt 20 ] && W_tool=20
564+
565+
# Fixed lead consumes: 4 (#) + 2 (gap) + W_tool + 1 (gap) = 7 + W_tool.
566+
local fixed=$(( 7 + W_tool ))
567+
local remaining=$(( total - fixed ))
568+
[ "$remaining" -lt 50 ] && remaining=50
569+
local W_match=$(( remaining * 60 / 100 ))
570+
local W_reason=$(( remaining - W_match - 1 ))
571+
[ "$W_match" -lt 30 ] && W_match=30
572+
[ "$W_reason" -lt 20 ] && W_reason=20
573+
574+
printf "%4s %-${W_tool}s %-${W_match}s %s\n" '#' 'tool' 'match-summary' 'reason'
575+
576+
local midx msum mreason match_lines=() reason_lines=() max_lines li msum_line reason_line pad_tool
577+
printf -v pad_tool '%*s' "$W_tool" ''
408578
for ((mi = 0; mi < mcount; mi++)); do
409579
mentry="$(jq -c ".[${mi}]" <<<"$members")"
410580
midx="$(jq -r '.index' <<<"$mentry")"
411-
mtool="$(truncate_str "$(rule_tool "$(jq -c '.rule' <<<"$mentry")")" 18)"
581+
mtool="$(rule_tool "$(jq -c '.rule' <<<"$mentry")")"
412582
[ -z "$mtool" ] && mtool="-"
413-
msum="$(truncate_str "$(match_summary "$(jq -c '.rule' <<<"$mentry")")" 50)"
583+
msum="$(match_summary "$(jq -c '.rule' <<<"$mentry")")"
414584
mreason="$(rule_reason "$(jq -c '.rule' <<<"$mentry")")"
585+
586+
wrap_text "$msum" "$W_match" match_lines
587+
wrap_text "$mreason" "$W_reason" reason_lines
588+
589+
max_lines=${#match_lines[@]}
590+
[ "${#reason_lines[@]}" -gt "$max_lines" ] && max_lines=${#reason_lines[@]}
591+
415592
if [ "$use_color" -eq 1 ]; then
416593
color="$(color_for_list "$list")"
417-
printf "${color}%4s %-18s %-50s %s${reset}\n" \
418-
"$midx" "$mtool" "$msum" "$mreason"
419594
else
420-
printf '%4s %-18s %-50s %s\n' \
421-
"$midx" "$mtool" "$msum" "$mreason"
595+
color=""
422596
fi
597+
598+
for ((li = 0; li < max_lines; li++)); do
599+
msum_line="${match_lines[$li]:-}"
600+
reason_line="${reason_lines[$li]:-}"
601+
if [ "$li" -eq 0 ]; then
602+
if [ "$use_color" -eq 1 ]; then
603+
printf "${color}%4s %-${W_tool}s %-${W_match}s %s${reset}\n" \
604+
"$midx" "$mtool" "$msum_line" "$reason_line"
605+
else
606+
printf "%4s %-${W_tool}s %-${W_match}s %s\n" \
607+
"$midx" "$mtool" "$msum_line" "$reason_line"
608+
fi
609+
else
610+
if [ "$use_color" -eq 1 ]; then
611+
printf "${color}%4s %s %-${W_match}s %s${reset}\n" \
612+
'' "$pad_tool" "$msum_line" "$reason_line"
613+
else
614+
printf "%4s %s %-${W_match}s %s\n" \
615+
'' "$pad_tool" "$msum_line" "$reason_line"
616+
fi
617+
fi
618+
done
423619
done
424620
done
425621
}

tests/bootstrap.bats

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,7 @@ EOF
684684
}
685685

686686
# ---------------------------------------------------------------------------
687-
# Read/Edit/Write skip cases that mirror Claude Code's own rejection rules.
687+
# Read/Edit/Write skip cases for clearly invalid path shapes.
688688
# ---------------------------------------------------------------------------
689689

690690
@test "bootstrap: Read(~user/.ssh) skipped (tilde variant not supported)" {

tests/common_match.bats

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ teardown() {
172172

173173
# Directory-prefix regex: the core motivating example from the plan - matches a bash
174174
# sub-argument path that native `Bash(prefix:*)` rules cannot express due to the
175-
# word-boundary check in src/tools/BashTool/bashPermissions.ts.
175+
# word-boundary check matching how native Bash permission prefixes behave.
176176
@test "match_rule: directory-prefix regex matches bash subcommand paths" {
177177
rule='{"tool":"Bash","match":{"command":"^bash /Users/[^/]+/\\.claude/plugins/.*/scripts/[a-z-]+\\.sh( |$)"}}'
178178
run match_rule "Bash" '{"command":"bash /Users/alice/.claude/plugins/cache/org-claude-passthru/scripts/verify.sh --quiet"}' "$rule"

0 commit comments

Comments
 (0)