Skip to content
Closed
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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@ not published here — see `docs/EVALS.md` for the instruments behind the headli

---

## [Unreleased]

### Fixed — the hooks' command-word rule no longer holds a Bash call for minutes on a long line (#327)

`rw_is_ripwire_call`, the one rule the three hooks share to decide whether a Bash line runs ripwire, rebuilt
the rest of the line for every character it read, so its cost grew with the cube of the line's length: 2.9 s at
2,000 characters, 20.6 s at 4,000 and 155 s at 8,000 under macOS bash 3.2. The PreToolUse meter runs it on every
Bash call, and one command carrying a heredoc held the call for 6 min 49 s. Two guards now run before the scan,
and both can only turn a call into a missed one, never create a false one:

- A line that does not contain the literal `ripwire` holds no call. The check reads the raw text before quote
removal, so a command word the shell assembles from quoted or escaped fragments (`'rip''wire' .`,
`rip\wire .`) now reads as no call; the scan alone read it as one.
- A line longer than 1,024 characters is not scanned and reads as no call.

For the substitution meter this is a numerator that can fall short on those two shapes
(`docs/SUBSTITUTION_METER.md`, "Known undercount"). `test/routehookcheck.sh` O10 holds the cost (4,000
characters: 20 s before, 0 s after) and O9 pins the four assembled-word shapes.

### Fixed — the prompt routers no longer time out on every prompt outside a git work tree (#327)

`hooks/ripwire-claude-route.sh` and `hooks/ripwire-codex-route.sh` ran `--help-task` for every prompt. Outside
a git work tree it has no file list from git and walks the whole tree under `cwd`: a session started in `$HOME`
still ran after 30 s, past the 8 s hook timeout, so Claude Code discarded the hook and printed a timeout warning
on every prompt (0.08 s for the same prompt in a git repository). Both routers now exit before the classifier
when git does not place `cwd` inside a work tree. A small non-git project gets no recommendation and
writes no routing row either. `test/routehookcheck.sh` O11 holds it with a stub `ripwire` that records each call.

## [0.6.2] — 2026-09-21

### Added — Microsoft's `cl.exe` builds the tree, so both Windows front ends compile and both gate
Expand Down
13 changes: 13 additions & 0 deletions docs/SUBSTITUTION_METER.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,13 @@ an unstated one makes it untrustworthy:
payload, so `detail` can be shorter still — `class` is unaffected, and the row stays valid JSON.
- **`nudge=gated`** rows come from a call outside a git repository or with no `ripwire` on `PATH`.
They are counted; they were never nudgeable.
- **The command-word rule skips two shapes of Bash line** (issue #327). Its scan cost grows with the
cube of the line's length, so two guards run first. A line longer than 1,024 characters is not
scanned. A line that does not contain the literal `ripwire` is not scanned either, and that check
reads the raw text before quote removal, so a command word the shell assembles from fragments
(`'rip''wire' .`, `rip\wire .`) is not seen. Both shapes read as no ripwire call: the numerator can
only fall short, never gain a false call. `test/routehookcheck.sh` O9 pins the assembled words and
O10 the cost.

The meter does not estimate around any of this. An unobserved call is absent, and absent is not zero
— the same rule the rest of the tool's output follows.
Expand Down Expand Up @@ -735,6 +742,12 @@ The **prompt router**'s unit is a prompt; its rows are `UserPromptSubmit` decisi
and it is the instrument the band pre-registered in [`EVALS.md` §4](EVALS.md) ("Claude Code prompt
router") is measured through.

Its population is prompts whose `cwd` is inside a git work tree (issue #327). Outside one, `--help-task`
has no file list from git and walks the whole tree under `cwd`; a session started in `$HOME` took over
30 s for one prompt, past the 8 s hook timeout, and the killed hook wrote no row. Both prompt routers
now exit before the classifier there. A small non-git tree used to finish in time and write a row; it
writes none now, so a readout that spans the change compares two populations.

Three things it shares with this meter, and one it does not:

- **The same arm.** `hooks/ripwire-claude-route.sh` resolves `arm` by the rules `meter_init` applies —
Expand Down
16 changes: 16 additions & 0 deletions hooks/ripwire-claude-route.sh
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ resolve_arm()
# control-operator branch, so the digit behind it is read as a command word. That can only ever cost a
# MISSED call, in a line where `ripwire` sits in exactly that position, and never a false one.
#
# COST (issue #327). Each character read rebuilds the rest of the line (`${rw_line#?}`, and the suffix match
# around it), so the scan grows with the cube of the line's length: 20 s for a 4,000-character line under macOS
# bash 3.2, in front of the tool call it only counts. Two guards come first. A line that does not contain the
# word as written holds no call, and that check ends the scan for nearly every command. It reads the raw text,
# before quote removal, so a command word the shell assembles from quoted or escaped fragments (`'rip''wire'`,
# `rip\wire`, `"rip""wire"`) reads as no call: a MISSED call, never a false one. A line longer than 1,024
# characters is not scanned and reads as no call — a MISSED call, the same direction as the limit above; 1,024
# costs under half a second at worst. test/routehookcheck.sh O10 holds the cost, O9 pins the assembled words.
#
# POSIX sh only, no bashisms: routehookcheck.sh extracts this block and runs it under `sh`.
rw_cmd_word()
{
Expand Down Expand Up @@ -171,6 +180,8 @@ rw_cmd_word()

rw_is_ripwire_call()
{
case "$1" in *ripwire*) ;; *) return 1 ;; esac
[ "${#1}" -le 1024 ] || return 1
rw_nl='
'
rw_tab="$( printf '\t' )"
Expand Down Expand Up @@ -327,6 +338,11 @@ command -v ripwire >/dev/null 2>&1 || exit 0
prompt="$( printf '%s' "$input" | jq -r '.prompt // .user_prompt // .input // empty' 2>/dev/null )"
cwd="$( printf '%s' "$input" | jq -r '.cwd // .workdir // empty' 2>/dev/null )"
[ -n "$prompt" ] && [ -n "$cwd" ] && [ -d "$cwd" ] || exit 0
# Route only inside a git work tree (issue #327). Outside one `--help-task` has no file list from git and
# walks the whole tree under cwd: a session started in $HOME measured over 30 s for one prompt, past the
# 8 s hook timeout the installer registers, on every prompt. The cost is routing in a small non-git
# project too; a missed recommendation is the direction this hook already takes on every doubt.
git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
session="$( printf '%s' "$input" | jq -r '.session_id // .conversation_id // empty' 2>/dev/null )"

# A very long prompt is a paste, not a task description, and `--help-task` is not built to read one.
Expand Down
16 changes: 16 additions & 0 deletions hooks/ripwire-codex-route.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ hash_text()
# control-operator branch, so the digit behind it is read as a command word. That can only ever cost a
# MISSED call, in a line where `ripwire` sits in exactly that position, and never a false one.
#
# COST (issue #327). Each character read rebuilds the rest of the line (`${rw_line#?}`, and the suffix match
# around it), so the scan grows with the cube of the line's length: 20 s for a 4,000-character line under macOS
# bash 3.2, in front of the tool call it only counts. Two guards come first. A line that does not contain the
# word as written holds no call, and that check ends the scan for nearly every command. It reads the raw text,
# before quote removal, so a command word the shell assembles from quoted or escaped fragments (`'rip''wire'`,
# `rip\wire`, `"rip""wire"`) reads as no call: a MISSED call, never a false one. A line longer than 1,024
# characters is not scanned and reads as no call — a MISSED call, the same direction as the limit above; 1,024
# costs under half a second at worst. test/routehookcheck.sh O10 holds the cost, O9 pins the assembled words.
#
# POSIX sh only, no bashisms: routehookcheck.sh extracts this block and runs it under `sh`.
rw_cmd_word()
{
Expand Down Expand Up @@ -94,6 +103,8 @@ rw_cmd_word()

rw_is_ripwire_call()
{
case "$1" in *ripwire*) ;; *) return 1 ;; esac
[ "${#1}" -le 1024 ] || return 1
rw_nl='
'
rw_tab="$( printf '\t' )"
Expand Down Expand Up @@ -245,6 +256,11 @@ command -v ripwire >/dev/null 2>&1 || exit 0
prompt="$( printf '%s' "$input" | jq -r '.prompt // .user_prompt // .input // empty' 2>/dev/null )"
cwd="$( printf '%s' "$input" | jq -r '.cwd // .workdir // empty' 2>/dev/null )"
[ -n "$prompt" ] && [ -n "$cwd" ] && [ -d "$cwd" ] || exit 0
# Route only inside a git work tree (issue #327). Outside one `--help-task` has no file list from git and
# walks the whole tree under cwd: a session started in $HOME measured over 30 s for one prompt, on every
# prompt. The cost is routing in a small non-git project too; a missed recommendation is the direction
# this hook already takes on every doubt.
git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
session="$( printf '%s' "$input" | jq -r '.session_id // .conversation_id // empty' 2>/dev/null )"

promptBytes="$( printf '%s' "$prompt" | wc -c | tr -d ' ' )"
Expand Down
11 changes: 11 additions & 0 deletions hooks/ripwire-nudge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,15 @@ meter_arg1=""
# control-operator branch, so the digit behind it is read as a command word. That can only ever cost a
# MISSED call, in a line where `ripwire` sits in exactly that position, and never a false one.
#
# COST (issue #327). Each character read rebuilds the rest of the line (`${rw_line#?}`, and the suffix match
# around it), so the scan grows with the cube of the line's length: 20 s for a 4,000-character line under macOS
# bash 3.2, in front of the tool call it only counts. Two guards come first. A line that does not contain the
# word as written holds no call, and that check ends the scan for nearly every command. It reads the raw text,
# before quote removal, so a command word the shell assembles from quoted or escaped fragments (`'rip''wire'`,
# `rip\wire`, `"rip""wire"`) reads as no call: a MISSED call, never a false one. A line longer than 1,024
# characters is not scanned and reads as no call — a MISSED call, the same direction as the limit above; 1,024
# costs under half a second at worst. test/routehookcheck.sh O10 holds the cost, O9 pins the assembled words.
#
# POSIX sh only, no bashisms: routehookcheck.sh extracts this block and runs it under `sh`.
rw_cmd_word()
{
Expand Down Expand Up @@ -599,6 +608,8 @@ rw_cmd_word()

rw_is_ripwire_call()
{
case "$1" in *ripwire*) ;; *) return 1 ;; esac
[ "${#1}" -le 1024 ] || return 1
rw_nl='
'
rw_tab="$( printf '\t' )"
Expand Down
4 changes: 3 additions & 1 deletion test/codexpromptroutecheck.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ command -v jq >/dev/null 2>&1 || { echo "jq required"; exit 2; }
"$BIN" --help=all 2>&1 | grep -q -- '--help-task=' \
|| { echo "codexpromptroutecheck: supplied binary does not expose --help-task"; exit 1; }

mkdir -p "$TMP/bin" "$TMP/repo/.git" "$TMP/home"
mkdir -p "$TMP/bin" "$TMP/repo" "$TMP/home"
# A real work tree, not an empty .git directory: the router routes only where `git rev-parse` answers.
git -C "$TMP/repo" init -q
cat >"$TMP/bin/ripwire" <<'SH'
#!/bin/sh
case "$*" in
Expand Down
64 changes: 63 additions & 1 deletion test/routehookcheck.sh
Original file line number Diff line number Diff line change
Expand Up @@ -678,9 +678,71 @@ o9 0 "grep -r 'ripwire;' src/"
o9 0 'echo "a; ripwire b"'
o9 0 '# ripwire .'
o9 0 'echo hi > ripwire'
# KNOWN LIMIT of the substring guard (issue #327), pinned so it cannot widen unnoticed. The guard tests the
# raw line for the literal `ripwire`, before quote removal, so a command word the shell ASSEMBLES from quoted
# or escaped fragments reads as no call. The lexer alone read each of these four as a call; they are missed
# calls, never false ones. Quote removal still applies when the word appears whole, which the last two hold.
o9 0 "'rip''wire' ."
o9 0 'rip\wire .'
o9 0 '"rip""wire" .'
o9 0 'rip"wire" .'
o9 1 '"ripwire" .'
o9 1 "'ripwire' ."
[ "$o9_bad" -eq 0 ] \
&& ok "O9 command-word rule: $o9_n shapes read correctly (19 wrapped/sequenced/operator-attached calls, 9 appearances that run nothing)" \
&& ok "O9 command-word rule: $o9_n shapes read correctly (21 wrapped/sequenced/operator-attached/quoted calls, 9 appearances that run nothing, 4 assembled words the guard misses by design)" \
|| no "O9 command-word rule: $o9_bad of $o9_n shapes read WRONG (listed above)"

# ═══════════════════════════════════════════════════════════════════════════════════════════════════
# O10 — the rule's cost does not grow with the command line (issue #327)
# ═══════════════════════════════════════════════════════════════════════════════════════════════════
# The lexer rebuilds the rest of the line for every character it reads, so one long command cost
# 2.9 s at 2,000 characters, 20.6 s at 4,000 and 155 s at 8,000 under macOS bash 3.2 — inside the
# PreToolUse hook, in front of the Bash call it was only meant to count. RED on the pre-fix block,
# measured while writing this: each line below took about 20 s. The answers are asserted too: a line
# with no `ripwire` in it holds no call, and a line past the cap reads as none — a missed call,
# never a false one, the same direction as the `2>&1` limit the block already discloses.
echo
echo "=== O10: a long command line costs the rule nothing ==="
o10_long="$( head -c 4000 /dev/zero | tr '\0' 'a' | fold -w 60 | tr '\n' ' ' )"
for o10_line in "$o10_long" "echo $o10_long; ripwire ."
do
o10_t0="$( date +%s )"
o10_got="$( sh "$TMP/rule.sh" "$o10_line" 2>/dev/null )"
o10_dt=$(( $( date +%s ) - o10_t0 ))
case "$o10_line" in *ripwire*) o10_what="holding a call past the cap" ;; *) o10_what="without ripwire" ;; esac
if [ "$o10_dt" -le 2 ] && [ "$o10_got" = "0" ]; then
ok "O10 a ${#o10_line}-character line $o10_what answers 0 in ${o10_dt} s"
else
no "O10 a ${#o10_line}-character line $o10_what took ${o10_dt} s and answered [$o10_got] (want 0 within 2 s)"
fi
done

# ═══════════════════════════════════════════════════════════════════════════════════════════════════
# O11 — the prompt router does not classify outside a git work tree (issue #327)
# ═══════════════════════════════════════════════════════════════════════════════════════════════════
# Outside a git work tree `--help-task` has no file list from git and walks the whole tree under cwd.
# A session started in $HOME measured over 30 s for one prompt, past the 8 s hook timeout, on every
# prompt. A stub ripwire records each call, so the arm measures whether the hook CALLS the classifier,
# not how fast one machine's tree happens to walk. RED on the pre-fix hook: the non-repo prompt
# reached the stub. The repo prompt is the positive control: the stub is reachable, so an empty call
# log for the non-repo prompt is not a stub that never runs.
echo
echo "=== O11: no classifier call outside a git work tree ==="
O11BIN="$TMP/o11bin"; mkdir -p "$O11BIN"
O11LOG="$TMP/o11.calls"
printf '#!/bin/sh\nprintf "%%s\\n" "$*" >>"%s"\n' "$O11LOG" >"$O11BIN/ripwire"
chmod +x "$O11BIN/ripwire"
H11="$TMP/h11"; mkdir -p "$H11"
: >"$O11LOG"
route_run "$H11" "$O11BIN:$PATH" "$( promptjson o11a "$NONREPO" "$RECPROMPT" )" >/dev/null 2>&1
[ -s "$O11LOG" ] \
&& no "O11 route: a prompt in a non-git cwd called ripwire: [$( tr '\n' ' ' <"$O11LOG" )]" \
|| ok "O11 route: a prompt in a non-git cwd never calls ripwire"
: >"$O11LOG"
route_run "$H11" "$O11BIN:$PATH" "$( promptjson o11b "$REPO" "$RECPROMPT" )" >/dev/null 2>&1
[ -s "$O11LOG" ] \
&& ok "O11 route: a prompt in a git work tree still calls ripwire (O11's positive control)" \
|| no "O11 route: a prompt in a git work tree never reached the stub, so O11 proved nothing"

echo
if [ "$fail" -eq 0 ]; then echo "ALL PASS"; exit 0; else echo "SOME CHECKS FAILED"; exit 1; fi