Skip to content

fix(hooks): two hook timeouts, the lexer's cubic cost and routing outside a git work tree - #328

Closed
KilimcininKorOglu wants to merge 3 commits into
redhat-et:mainfrom
KilimcininKorOglu:fix/rw-lexer-cost
Closed

KilimcininKorOglu wants to merge 3 commits into
redhat-et:mainfrom
KilimcininKorOglu:fix/rw-lexer-cost

Conversation

@KilimcininKorOglu

@KilimcininKorOglu KilimcininKorOglu commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #327.

This PR fixes two hook timeouts reported in #327. Each has its own commit.

  1. rw_is_ripwire_call rebuilt the rest of the line for every character it read, so its cost grew with the cube of the command's length. The PreToolUse meter runs it on every Bash call, and one command carrying a heredoc held the call for 6 min 49 s. (3b96d37)
  2. The UserPromptSubmit routers run ripwire "$cwd" --help-task="$prompt" on every prompt. 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 took over 30 s for one prompt, past the 8 s hook timeout, so Claude Code discarded the hook and printed UserPromptSubmit hook [...] timed out after 8s on every prompt. (bb04971, reported in this comment)

Change 1: the lexer's cost

Two guards at the top of the mirrored block, identical in all three copies (hooks/ripwire-nudge.sh, hooks/ripwire-claude-route.sh, hooks/ripwire-codex-route.sh):

case "$1" in *ripwire*) ;; *) return 1 ;; esac
[ "${#1}" -le 1024 ] || return 1
  • A line that does not contain the word holds no call. This one is exact and ends the scan for nearly every command.
  • A line over 1,024 characters is not scanned and reads as no call. That is a missed call, never a false one: the same direction as the 2>&1 limit the block already discloses. The block's comment now names both, and why 1,024: the old scan of a 1,000-character line with the call at the end took 0.43 s on bash 3.2.

The lexer itself is unchanged. A linear lexer (one awk pass, say) would remove the cap too; this PR keeps the diff to the guards and leaves that choice to you.

Change 2: routing outside a git work tree

Both prompt routers (hooks/ripwire-claude-route.sh, hooks/ripwire-codex-route.sh) now exit before the classifier when the prompt's cwd is not inside a git work tree:

git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0

Measured with the same prompt on macOS, warm cache:

cwd ripwire "$cwd" --help-task=...
a git repository 0.08 s
an empty non-git directory 0.00 s
$HOME (not a git work tree) still running when stopped at 30 s

The cost: a small non-git project gets no recommendation either. That is a missed recommendation, the direction the router already takes on every doubt. A walk budget or file cap inside --help-task for a root without git would keep those projects routed; that is a change to the binary, so I left it to you.

Gates

test/routehookcheck.sh, both arms written first and observed RED on the old code:

  • O10, change 1:

    line old block this PR
    4,066 characters, no ripwire 20 s, answered 0 0 s, answers 0
    echo <4,000 characters>; ripwire . 20 s, answered 1 0 s, answers 0 (the cap)

    O9's 28 shapes and its byte-identity arm stay green.

  • O11, change 2: a stub ripwire on PATH records each call. A prompt in a non-git cwd must not reach it; on the old hook it did. A prompt in the fixture repo must reach it, which proves the stub runs at all.

test/codexpromptroutecheck.sh built its fixture repo as an empty .git directory, which git rev-parse does not accept. It now runs git init, and nothing else in the gate changed.

Verification

Run on macOS 27.0 (bash 3.2.57 as sh) with the released ripwire 0.6.2 as BIN; I did not build the tree. Every gate that names a changed file, per CONTRIBUTING §1 clause 2:

  • green: routehookcheck (70 PASS, including O10 and O11), hookcheck (199), codexdoctorcheck, codexpromptroutecheck, meterdisclosurecheck, routingreportcheck, skillinstallcheck, skillscanreadcheck, taskroutecheck, toolcallroutecheck, wrapverbscheck
  • regression.sh names codexpromptroutecheck.sh but stops at its first line without build/ripwire, so I ran that gate directly (green above).
  • releaseinstallcheck fails one arm, "installer gates escaped their fixture homes or failed with inherited overrides". It fails the same arm with the same message on unmodified main at b939ef4, and this diff does not touch install.sh, so the failure is not caused by this change. I have not investigated its cause. Two conditions of my run differ from CI and may explain it: the gates ran from a shell inside a Claude Code session, which exports its own environment variables to child processes, and BIN was the released 0.6.2 binary instead of a build of this tree.

Summary by CodeRabbit

  • Performance
    • Command checks now finish faster for lines that do not contain “ripwire,” and unusually long command lines are skipped to keep checks responsive.
    • Commands longer than 1,024 characters may not be recognized as Ripwire calls.
  • Tests
    • Added checks to verify that these cases are handled promptly.

…r 4,000 characters

rw_is_ripwire_call rebuilt the rest of the line for every character it
read, so one long Bash command held the PreToolUse hook for minutes:
2.9 s at 2,000 characters, 20.6 s at 4,000, 155 s at 8,000 under macOS
bash 3.2 (bash 5.3: 1.7 s, 12.4 s, 93.6 s). The meter runs it on every
Bash call even with the nudge text retired.

Two guards now come first, in all three copies of the mirrored block. A
line without the word holds no call; that is exact and ends the scan for
nearly every command. A line over 1,024 characters is not scanned and
reads as no call, a missed call, the same direction as the 2>&1 limit
the block already discloses.

routehookcheck O10 holds both: two 4,000-character lines, 20 s each and
one wrong answer on the old block, 0 s and correct now.

Fixes redhat-et#327
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: redhat-et/ripwire/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 4c199171-3cfd-492d-a7ef-4cb7cda3381f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: redhat-et/ripwire/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 85042680-fd2c-4f21-9523-dad4ee917ebd

📥 Commits

Reviewing files that changed from the base of the PR and between b939ef4 and 3b96d37.

📒 Files selected for processing (4)
  • hooks/ripwire-claude-route.sh
  • hooks/ripwire-codex-route.sh
  • hooks/ripwire-nudge.sh
  • test/routehookcheck.sh

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Three mirrored hooks now skip the character scan when a command lacks ripwire or exceeds 1,024 characters. A new O10 test checks response time for long inputs without a call and with a call beyond the limit.

Changes

Ripwire call scan guards

Layer / File(s) Summary
Add scan guards and verify long inputs
hooks/ripwire-claude-route.sh, hooks/ripwire-codex-route.sh, hooks/ripwire-nudge.sh, test/routehookcheck.sh
Each hook returns no-call before lexing if the input lacks ripwire or exceeds 1,024 characters. O10 checks responses for 4,000-character inputs without ripwire and with a call beyond the limit.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: joyful-ii-v-i

Merge Risk: ⚪ Minimal · up to 3b96d

The scan guards address slow long commands. Calls in commands over the limit may be missed by design; no additional merge-blocking issue is established.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #327 requires a fast exact exit for commands without ripwire and either a bounded scan or a more efficient lexer for remaining commands. The reviewed head adds the substring guard and the 1,02…
Out of Scope Changes check ✅ Passed The reviewed changes are limited to the three mirrored rw_is_ripwire_call guards and the related O10 performance tests in test/routehookcheck.sh. The guards and tests directly address issue #327. …
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main lexer-performance and hook-timeout fix. The supplied changes do not show routing outside a git work tree, but the title remains materially related to the primary …
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@joyful-ii-V-I

Copy link
Copy Markdown
Collaborator

Thanks for chasing this down — the root-cause writeup in #327 was already excellent, and turning it into
a guard-first fix that keeps the lexer itself untouched is the right shape of change: small diff, clear
direction ("only ever a missed call"), and O10 pins the exact regression with a red-on-base/green-on-head
arm. I rebuilt from origin/main at b939ef4f, applied this PR, and confirmed:

  • The three mirrored copies are still byte-identical (md5 on the extracted BEGIN/END MIRRORED BLOCK),
    and O9's byte-identity arm plus all 28 of its shape checks stay green.
  • O10 is genuinely red on main and green here — I isolated the same two assertions against the
    pre-fix block by hand and reproduced the exact failure you describe (the past-cap line answers 1 in
    ~2s on base, 0 in under 20ms on this PR).
  • The length-cap's direction holds at the boundary: a 1,024-char line with the call still catches it
    (0.45s, matches the old lexer); at 1,025 it's correctly dropped. Never a false positive in anything I
    threw at it.
  • All 13 gates that reference these three hook files by name (routehookcheck, hookcheck,
    codexdoctorcheck, codexpromptroutecheck, meterdisclosurecheck, routingreportcheck, skillinstallcheck,
    skillscanreadcheck, taskroutecheck, toolcallroutecheck, wrapverbscheck, claudeconfigdircheck, and
    releaseinstallcheck which fails the same pre-existing arm on unmodified main) pass green against a
    fresh build of this branch.

One thing I want to flag before this merges, since it touches the comment's own claim of exactness:

The substring guard (case "$1" in *ripwire*) ;; *) return 1 ;; esac) isn't exact. It tests the raw,
unprocessed command text for the literal 7-character substring ripwire. The old lexer, by contrast,
does real POSIX quote-removal and backslash-unescaping before comparing the resulting word. So a command
that assembles the word from more than one quoted or escaped fragment — 'rip''wire' ., rip\wire .,
"rip""wire" ., rip"wire" . — all of which a real shell executes as ripwire . — gets caught by the
old lexer (rw_is_ripwire_call returns 1/true) but silently dropped by the new guard (returns 0/false)
before the lexer even runs. I checked this against both the base and this PR's extracted
rw_is_ripwire_call directly; happy to share the one-liners.

It's a miss, not a false positive, so it stays on the side the PR promises — but it's a second, new,
undisclosed miss, not the one the comment names. The comment currently says the substring check "is exact
and ends the scan for nearly every command," which this disproves. Given the hook already has a "KNOWN
LIMIT, disclosed rather than papered over" paragraph for the pre-existing 2>&1 case, I'd suggest the
same treatment here: soften "exact" to name this shape, maybe with an O9-style pinned test case so it
can't drift further unnoticed. I don't think the guard itself needs to change — this is a narrow,
unusual shape to hit by accident — just the claim.

Small second note: this PR doesn't touch CHANGELOG.md or docs/SUBSTITUTION_METER.md. The repo has a
strong existing convention of logging exactly this class of change (a floor that silently changes what
gets classified), so I'd bundle a short CHANGELOG entry in here too if you're doing another pass for the
comment fix above.

Neither of these blocks the performance fix's own correctness — O10's red/green story is solid and the
length-cap direction is exactly as claimed. I'd like the comment wording (and ideally CHANGELOG) settled
before merge, since "exact" is doing real work in how a future reader trusts this guard.

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 UserPromptSubmit timeout, so Claude
Code discarded the hook and printed a timeout warning on every prompt.
Both prompt routers now exit before the classifier when
`git rev-parse --is-inside-work-tree` does not answer.

routehookcheck.sh O11 puts a stub ripwire on PATH that records each
call: a prompt in a non-git cwd must not reach it, and a prompt in the
fixture repo must (the positive control). RED on the pre-fix hook.
codexpromptroutecheck.sh built its fixture repo as an empty .git
directory, which git does not accept; it now runs `git init`.

Refs redhat-et#327
@KilimcininKorOglu KilimcininKorOglu changed the title fix(hooks): the command-word lexer cost the cube of the line, 20 s for 4,000 characters fix(hooks): two hook timeouts, the lexer's cubic cost and routing outside a git work tree Sep 23, 2026
… both fixes

The substring guard reads the raw line before quote removal, so a command
word the shell assembles from fragments ('rip''wire' ., rip\wire .,
"rip""wire" ., rip"wire" .) reads as no call; the lexer alone read each
as one. The block's comment called the check exact. It now names the
shape and its direction (a missed call, never a false one) in all three
mirrored copies, and O9 pins the four shapes plus two whole quoted words
that still read as calls.

CHANGELOG gains an Unreleased section with both redhat-et#327 fixes, and
docs/SUBSTITUTION_METER.md names the guard's two skipped shapes under
"Known undercount" and the prompt router's git-only population.

Refs redhat-et#327
@KilimcininKorOglu

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review and the rebuild. You were right on both points, and both are addressed in b69ce56.

The substring guard is not exact. I reproduced it. With the guard removed from the extracted block, the lexer reads 'rip''wire' ., rip\wire ., "rip""wire" . and rip"wire" . as calls (1). With the guard, all four read as no call (0). The guard itself is unchanged, as you suggested. The claim is fixed:

  • The COST comment, byte-identical in all three hooks, no longer says "exact". It now says the check reads the raw text before quote removal. It names the assembled-word shape and its direction: a missed call, never a false one.
  • O9 pins the four shapes as 0. It also pins "ripwire" . and 'ripwire' . as 1, which shows quote removal still works when the word appears whole. O9's byte-identity arm stays green.

CHANGELOG and the meter doc.

  • CHANGELOG.md gets an [Unreleased] section with one Fixed entry per change in this PR (the lexer guards, and the routing change from bb04971, described below).
  • docs/SUBSTITUTION_METER.md names the guard's two skipped shapes under "Known undercount", because they can only shorten the numerator. It also states that the prompt router's population is now prompts inside a git work tree, so a readout that spans this change compares two populations.

A second change you have not reviewed yet. Your review covered 3b96d37. After it I pushed bb04971, which fixes a second hook timeout reported in #327 (comment). Outside a git work tree, --help-task walks the whole tree under cwd. In $HOME it was still running after 30 s, past the 8 s UserPromptSubmit timeout, so every prompt printed a timeout warning. Both prompt routers now exit before the classifier when cwd is not inside a git work tree. O11 tests this with a stub ripwire that records each call. codexpromptroutecheck.sh built its fixture as an empty .git directory; it now runs git init. That change drops routing in small non-git projects too. If you prefer a walk budget inside --help-task, I can drop that commit.

Gates, run with the released 0.6.2 binary (I did not build the tree): all 23 gates that name a changed file, the CHANGELOG or the meter doc. 22 are green, routehookcheck at 70 PASS. deckcheck flagged a git flag I had quoted in the CHANGELOG; I reworded the sentence, and it is green now. regression.sh fails --situ, --mentions and --mcp. It fails the same three with the same binary on unmodified main at b939ef4, so a build of this tree is the right place to check it.

@joyful-ii-V-I joyful-ii-V-I left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, @KilimcininKorOglu. The fix round is exactly what we asked for, and the second commit fixes a real problem well. I built b69ce563 from source. Your gate numbers came from the 0.6.2 binary, so this is the tree build you said was still needed. Here is what I checked.

The substring-guard disclosure (our ask from the last round): done.

  • The COST comment no longer says "exact". It names the assembled-word shape and says which way it fails, and the O9 byte-identity arm shows the comment is the same in all three hooks (158 lines).
  • O9 pins 'rip''wire' ., rip\wire ., "rip""wire" . and rip"wire" . as 0, and pins the two whole-quoted controls as 1. I also ran the extracted block under dash, the sh on Linux CI, and under bash. They give the same answer for all of these shapes and for the 1,025+ character cap.
  • The CHANGELOG [Unreleased] entries and the SUBSTITUTION_METER.md "Known undercount" note are accurate. deckcheck and meterdisclosurecheck pass on them.

bb04971e, routing only inside a git work tree: reviewed, and we're keeping it.

  • It is the right fix for #327's second timeout. It is one cheap git -C "$cwd" rev-parse before the classifier, and it errs toward a missed recommendation, as the hook already does whenever it is unsure. hooks/ripwire-claude-toolroute.sh already skips non-repos with a git rev-parse check, so the routers now match it.
  • O11 fails when I delete your guard line and passes with it (the non-git prompt reached the stub). The positive control keeps the test from passing without calling anything. The git init change in codexpromptroutecheck.sh was needed: the old empty .git directory was not a real repo.
  • I ran both routers on a matrix of 13 cwd shapes: a plain repo, a subdirectory, a linked git worktree (its path contains a space), a repo under a directory with spaces, a submodule, a non-repo, a directory with an empty .git, and the GIT_DIR / GIT_WORK_TREE cases. Every case routes or skips the way the comment says. "$cwd" is quoted everywhere, so spaces are fine.

Two small notes. Neither blocks the merge. Take them or leave them:

  1. In a bare repository, and inside a .git directory, git rev-parse --is-inside-work-tree prints false and exits 0, so the guard lets those through and the classifier runs. Those trees are small, so this is not a timeout. But "does not answer" is doing some work in the comment. [ "$(git -C "$cwd" rev-parse --is-inside-work-tree 2>/dev/null)" = true ] || exit 0 would make it exact. (--show-toplevel would not help here, because it also exits 0 in a bare repo.)
  2. If the client itself runs with GIT_DIR exported, any cwd counts as "inside", because git uses GIT_DIR instead of searching from cwd. And with no git on PATH, the router never routes at all. Both fail toward a missed call. A half-sentence in the comment would cover them.

Gates. I ran every gate that names a file this PR changes: 23 gates, including routehookcheck (70 PASS), hookcheck, taskroutecheck, toolcallroutecheck, releaseinstallcheck, skillinstallcheck, codexdoctorcheck, selfcheckcheck and deckcheck. All 23 pass on a tree build of b69ce563. The PR touches no C++ and does not bump kParserVer.

Merging. A trial merge onto our current integration branch conflicts only in CHANGELOG.md, where both sides open ## [Unreleased]. We'll resolve that ourselves by keeping both sets of entries under one heading. After that merge, routehookcheck, codexpromptroutecheck, deckcheck and meterdisclosurecheck still pass. There is nothing to rebase on your side. We'll bring your branch in as it is with the next integration train, so the commits stay yours. A maintainer will approve the held Actions run so the Linux and Windows jobs report before it lands.

Thanks again. The #327 write-up and this PR are a model of how to report and fix a performance cliff.

joyful-ii-V-I added a commit that referenced this pull request Sep 25, 2026
train 19: silent cuts disclosed, a Windows x64 release asset (preview), UB-class compiler fences and a zero-row clang-tidy gate, the Ruby attribute DSL (#310), the hooks' lexer cost (#328)
@joyful-ii-V-I

Copy link
Copy Markdown
Collaborator

This landed on main through train 19 (#332, merge acf3f840). Your three commits are on main unchanged, up to b69ce563. GitHub shows this PR as "Closed" rather than "Merged" only because the train's "Closes #328" fired first; the work is in, and it ships in 0.6.3. Thank you, @KilimcininKorOglu, for the #327 report and the fix. You're credited in the 0.6.3 release notes.

s0undt3ch pushed a commit to s0undt3ch/ripwire that referenced this pull request Sep 25, 2026
…rd lexer cost, and routing only inside a git work tree

Signed head b69ce56 (KilimcininKorOglu),
merged as-is; the author's commits are kept.

Conflict: CHANGELOG.md, both sides opening ## [Unreleased]. Union: the
PR's two (redhat-et#327) sections go at the end of the [Unreleased] block, above
## [0.6.2]; one [Unreleased] heading remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rw_is_ripwire_call is cubic in command length and holds a PreToolUse Bash call for minutes

2 participants