Summary
rw_is_ripwire_call in hooks/ripwire-nudge.sh takes time cubic in the length of the command line. The PreToolUse hook runs it before every Bash tool call, so a long command (for example one that carries a heredoc script) holds the tool call for minutes. In one Claude Code session a single Bash call stayed at running PreToolUse hooks… 3/4 while ripwire-nudge.sh ran at 99% CPU for 6 min 49 s, with no child process and stdin already closed.
The file header says the hook "exits fast" and that "the common case … bails after one or two greps over stdin". This path contradicts that, and it runs even though the nudge text is retired: the meter still classifies every Bash call.
Measurement
The mirrored block alone, extracted from the installed hook and called on a synthetic line of a characters with spaces (no ripwire in it):
| Command length |
bash 3.2.57 (macOS /bin/bash) |
bash 5.3.20 (Homebrew) |
| 2,000 chars |
2.9 s |
1.7 s |
| 4,000 chars |
20.6 s |
12.4 s |
| 8,000 chars |
155.4 s |
93.6 s |
Each doubling costs about 7.5-8x on both shells, so a newer bash lowers the constant by about 1.7x and leaves the growth unchanged. At that rate a 16 KB command takes about 20 minutes on bash 3.2 and about 12 on bash 5.3. The hook's #!/usr/bin/env bash takes whichever bash comes first on PATH, and on a stock Mac that is 3.2.
Reproduction
awk '/BEGIN MIRRORED BLOCK rw_is_ripwire_call/,/END MIRRORED BLOCK rw_is_ripwire_call/' \
hooks/ripwire-nudge.sh > /tmp/rwlex.sh
for n in 2000 4000 8000; do
s=$(head -c $n /dev/zero | tr '\0' 'a' | fold -w 60 | tr '\n' ' ')
/usr/bin/time -p bash -c '. /tmp/rwlex.sh; rw_is_ripwire_call "$1"' _ "$s"
done
Cause
The lexer takes one character per iteration and rebuilds the remaining line each time:
rw_c="${rw_line%"${rw_line#?}"}"
rw_line="${rw_line#?}"
${rw_line#?} copies the rest of the line (O(n)), and ${rw_line%"${rw_line#?}"} builds that copy again and then matches it as a suffix pattern against the whole line. That suffix match is itself super-linear on both bash versions measured, which gives the roughly O(n³) total the table shows.
The same loop sits in all three copies of the mirrored block, so this one report covers all of them:
hooks/ripwire-nudge.sh (the PreToolUse meter, where the stall was observed)
hooks/ripwire-claude-route.sh
hooks/ripwire-codex-route.sh
The route hooks were not timed separately; they run the same code on the command they are handed.
Suggested fix
- A cheap early exit, POSIX and exact: a line that does not contain the substring
ripwire cannot hold a ripwire call, so case "$1" in *ripwire*) ;; *) return 1 ;; esac at the top of rw_is_ripwire_call removes the cost for almost every command.
- For the remaining lines, a length cap (for example, lines over a few KB are scored as unclassified), or a lexer that does not rewrite the string per character (a single
awk pass over the line, one fork).
Environment
- ripwire 0.6.2 (built_from=15a20855c); the mirrored block is byte-identical to
hooks/ripwire-nudge.sh on main today
- macOS 27.0, arm64
- GNU bash 3.2.57 (
/bin/bash, which /usr/bin/env bash resolved to when the stall happened) and GNU bash 5.3.20 (Homebrew)
- Claude Code 2.1.280, hook installed with
skills/install.sh --hook
Summary
rw_is_ripwire_callinhooks/ripwire-nudge.shtakes time cubic in the length of the command line. The PreToolUse hook runs it before every Bash tool call, so a long command (for example one that carries a heredoc script) holds the tool call for minutes. In one Claude Code session a single Bash call stayed atrunning PreToolUse hooks… 3/4whileripwire-nudge.shran at 99% CPU for 6 min 49 s, with no child process and stdin already closed.The file header says the hook "exits fast" and that "the common case … bails after one or two greps over stdin". This path contradicts that, and it runs even though the nudge text is retired: the meter still classifies every Bash call.
Measurement
The mirrored block alone, extracted from the installed hook and called on a synthetic line of
acharacters with spaces (noripwirein it):/bin/bash)Each doubling costs about 7.5-8x on both shells, so a newer bash lowers the constant by about 1.7x and leaves the growth unchanged. At that rate a 16 KB command takes about 20 minutes on bash 3.2 and about 12 on bash 5.3. The hook's
#!/usr/bin/env bashtakes whichever bash comes first on PATH, and on a stock Mac that is 3.2.Reproduction
Cause
The lexer takes one character per iteration and rebuilds the remaining line each time:
${rw_line#?}copies the rest of the line (O(n)), and${rw_line%"${rw_line#?}"}builds that copy again and then matches it as a suffix pattern against the whole line. That suffix match is itself super-linear on both bash versions measured, which gives the roughly O(n³) total the table shows.The same loop sits in all three copies of the mirrored block, so this one report covers all of them:
hooks/ripwire-nudge.sh(the PreToolUse meter, where the stall was observed)hooks/ripwire-claude-route.shhooks/ripwire-codex-route.shThe route hooks were not timed separately; they run the same code on the command they are handed.
Suggested fix
ripwirecannot hold a ripwire call, socase "$1" in *ripwire*) ;; *) return 1 ;; esacat the top ofrw_is_ripwire_callremoves the cost for almost every command.awkpass over the line, one fork).Environment
hooks/ripwire-nudge.shonmaintoday/bin/bash, which/usr/bin/env bashresolved to when the stall happened) and GNU bash 5.3.20 (Homebrew)skills/install.sh --hook