From b2e6f2689036a538edd18aded0951090d30a4111 Mon Sep 17 00:00:00 2001 From: felix-xtrace Date: Mon, 21 Sep 2026 10:55:13 -0700 Subject: [PATCH 1/3] fix(rulebook): raise the hook's regex length bound from 400 to 2000 (v0.75.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_RX_MAX` dropped the WHOLE rule, silently, for any pattern over 400 characters. Length is not the backtracking guard — `_RX_NESTED` is: a 60-character pattern can stall and a 1,500-character alternation of literal paths cannot. 400 refused the second, which is a real rule shape (`path_rx` / `cmd_rx` over a list of paths), and bought nothing against the first. The bound stays a bound (2000) so one server rule cannot cost unbounded compile time or storage. A hook older than this release still drops a rule carrying a longer pattern; MemHub-Backend floors such a rule at `min_hook_version` 0.75.0 in the paired change. Co-Authored-By: Claude Fable 5.1 --- plugins/memhub-staging/.claude-plugin/plugin.json | 2 +- plugins/memhub-staging/.codex-plugin/plugin.json | 2 +- plugins/memhub-staging/.mcp.json | 2 +- plugins/memhub/.claude-plugin/plugin.json | 2 +- plugins/memhub/.codex-plugin/plugin.json | 2 +- plugins/memhub/.cursor-plugin/plugin.json | 2 +- plugins/memhub/.mcp.json | 2 +- plugins/memhub/mcp.json | 2 +- plugins/memhub/plugin.json | 2 +- plugins/memhub/scripts/rulebook_hook.py | 10 ++++++++-- tests/rulebook_hook_test.py | 13 +++++++++++++ tests/rulebook_verify_test.py | 2 +- 12 files changed, 31 insertions(+), 12 deletions(-) diff --git a/plugins/memhub-staging/.claude-plugin/plugin.json b/plugins/memhub-staging/.claude-plugin/plugin.json index 407ec99f..c9347d87 100644 --- a/plugins/memhub-staging/.claude-plugin/plugin.json +++ b/plugins/memhub-staging/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub-staging", "description": "STAGING build of the MemHub plugin \u2014 identical behavior to `memhub` but pointed at the staging backend (api.staging.memhub.xtrace.ai) for MemHub developers iterating on the service. Skills, hooks, and scripts are shared with `memhub` (symlinked), so the two never drift; only the backend URL and OAuth client differ. Install this instead of `memhub` \u2014 never both (both register an MCP server named `memhub`). Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.75.0", "license": "Apache-2.0", "hooks": "./hooks/claude-hooks.json", "author": { diff --git a/plugins/memhub-staging/.codex-plugin/plugin.json b/plugins/memhub-staging/.codex-plugin/plugin.json index 394c2f24..243d0160 100644 --- a/plugins/memhub-staging/.codex-plugin/plugin.json +++ b/plugins/memhub-staging/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub-staging", "description": "MemHub staging for Codex: team memory and Rulebook hooks. Use the setup skill to verify the active hook route and credential. Repository shell commands in projectless tasks must include an explicit cd prefix when the host omits workdir. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.75.0", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub-staging/.mcp.json b/plugins/memhub-staging/.mcp.json index 393af657..51e6e9e6 100644 --- a/plugins/memhub-staging/.mcp.json +++ b/plugins/memhub-staging/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "memhub": { "type": "http", - "url": "https://api.staging.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.0", + "url": "https://api.staging.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.75.0", "auth": { "CLIENT_ID": "mYTjrWldX9ZFGtDES3hQDEHSis9gIjiq" }, diff --git a/plugins/memhub/.claude-plugin/plugin.json b/plugins/memhub/.claude-plugin/plugin.json index 644f6c8c..8e286831 100644 --- a/plugins/memhub/.claude-plugin/plugin.json +++ b/plugins/memhub/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", "description": "Auto-capture Claude Code sessions into MemHub team memory. A Stop hook ships each turn as it happens \u2014 sending only the transcript bytes written since the last successful flush, so a session is captured even if it never reaches a clean exit \u2014 alongside flushes on commit/PR events (PostToolUse hooks) and a SessionEnd backstop. The memhub MCP runs tool-aware (agentic) extraction of facts, episodes, and artifacts with watermark-based delta dedup, batching extraction so per-turn capture doesn't fragment episodes. A PreToolUse hook surfaces situated team directives (lessons/procedures) before each Edit/Write/Bash by firing the memhub recall_directives symbol-tripwire on the in-flight call and injecting any hits as context (a client-side precision gate drops directives whose triggers don't concretely match the call, e.g. an over-broad repo-name trigger). Includes skills for: plugin install/health (setup), plugin authentication with its own hook credential (login), repo brain creation and seeding (onboard), terminal artifact uploads (save-artifact), on-demand session import (import-session), team-memory recall (search-memory), teammate session handoff (handoff-session), git-authored spec development with ownership frontmatter (spec), Rulebook rule authoring (create-rule), rules proposed from CLAUDE.md and past sessions in one run (start-rulebook), and PR babysitting (pr-babysit) \u2014 a hook arms a self-paced loop after `gh pr create` that fixes review-bot findings and saves the fixing process to the repo's agent brain. Rules live in RULEBOOKS \u2014 containers with their own membership, so a rule reaches exactly the people its book binds, and one person can be bound by several (their org's book plus their team's): the authoring skills resolve which book a rule lands in and offer to create one when there is none, the hooks cache every book that binds you, and when two books fire on one call the wider book's rule is the one the per-call cap keeps. A PostToolUse hook reminds authors when an edited file belongs to a git-authored spec. Spec ownership frontmatter replaces the retired artifact map; the backend maintains read-only mirrors and opens bootstrap or audit PRs. Sessions are named with the title their own host shows \u2014 Claude Code's generated title, or on Codex the thread_name Codex generated (from the rollout, else ~/.codex/session_index.jsonl), passed through verbatim so MemHub's sessions list matches the editor's; a session its host never named falls back to a title derived from its first prompt \u2014 and route into the repo's own agent brain via a per-user room cache (~/.config/memhub-plugin/rooms.json, never written into the repo) \u2014 resolved once by /memhub:onboard and read by every writer, including the two automatic capture paths, so team memory lands in the repo's room instead of personal memory. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). Every rulebook fire is now disclosed to the user in a fixed shape on two channels: the hook's own terminal line leads with `\ud83d\udccf Rule fired: ` (`\u26d4\ufe0f` when a gate actually blocked the call) above the detail line it already showed, and the agent is told to echo that same byte-identical line at the top of its reply so the fire reaches the transcript everything downstream reads. Session start now says what rules ARE \u2014 standing instructions from teammates carrying CLAUDE.md's weight \u2014 before listing them. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.75.0", "license": "Apache-2.0", "hooks": "./hooks/claude-hooks.json", "author": { diff --git a/plugins/memhub/.codex-plugin/plugin.json b/plugins/memhub/.codex-plugin/plugin.json index cffa3c72..8f474aa6 100644 --- a/plugins/memhub/.codex-plugin/plugin.json +++ b/plugins/memhub/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "memhub", "description": "XTrace MemHub team memory in Codex: automatic session capture, situated directive recall, artifact sync, plus skills for setup, login, onboarding, artifact upload, session import, search, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Run memhub:setup to verify hook routing and authentication. Current desktop builds support bundled hooks; older hosts need the user bridge. Bundled handlers defer to matching installed user bridge handlers, which require trust. Codex's own threads are not captured as your sessions: spawned subagents, guardian action-reviews and memory consolidation are recognised from the rollout's thread_source and skipped by name, while every other kind \u2014 including a product surface we have not seen, since Codex's ThreadSource type is open-ended \u2014 is captured, because refusing an unknown kind would silently and unrecoverably drop real work. Every rule fire is reported under the same namespaced session id capture uploads, so a fire is linked to the Codex session it fired in. Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.75.0", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub/.cursor-plugin/plugin.json b/plugins/memhub/.cursor-plugin/plugin.json index 35ac427b..a042de70 100644 --- a/plugins/memhub/.cursor-plugin/plugin.json +++ b/plugins/memhub/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "memhub", "displayName": "MemHub", "description": "XTrace MemHub team memory in Cursor: search_memory / save_artifact / import_conversation tools plus skills for setup, login, onboarding, artifact upload, session import, recall, handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Sessions are captured automatically. Session \u2194 PR linking ships as skills on Cursor: link-pr and find-contributing-sessions. Cursor's shell hooks are observational \u2014 `afterShellExecution` defines no reply the agent can see \u2014 so the automatic post-`gh pr create` link that Claude Code and Codex get is not available here. The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.75.0", "license": "Apache-2.0", "author": { "name": "XTrace" diff --git a/plugins/memhub/.mcp.json b/plugins/memhub/.mcp.json index f6deebb3..6effefd0 100644 --- a/plugins/memhub/.mcp.json +++ b/plugins/memhub/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "memhub": { "type": "http", - "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.0", + "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.75.0", "auth": { "CLIENT_ID": "xHoQkYd7uNUfaNX6Tyv143e1Ev7u3XS0" }, diff --git a/plugins/memhub/mcp.json b/plugins/memhub/mcp.json index 3431ac71..6dc3c829 100644 --- a/plugins/memhub/mcp.json +++ b/plugins/memhub/mcp.json @@ -3,7 +3,7 @@ "mcpServers": { "memhub": { "type": "streamable-http", - "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.74.0" + "url": "https://api.memhub.xtrace.ai/mcp-server/mcp?memhub_plugin_version=0.75.0" } } } diff --git a/plugins/memhub/plugin.json b/plugins/memhub/plugin.json index bc97c8e0..cd43b67b 100644 --- a/plugins/memhub/plugin.json +++ b/plugins/memhub/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "memhub", "description": "XTrace MemHub team memory: search_memory / save_artifact / import_conversation MCP tools plus skills for setup, login, onboarding, artifact upload, session import, team-memory recall, session handoff, spec-driven development, Rulebook rule authoring (create-rule) and a team's first Rulebook — tested starter rules fitted to the repo, and rules proposed from CLAUDE.md + past sessions (start-rulebook), and PR babysitting. A rule lives in a RULEBOOK \u2014 a container with its own membership, so it reaches exactly the people that book binds; the authoring skills resolve which book it lands in and offer to create one when there is none. Session auto-capture ships through the host-native plugin layer (hooks are outside the Agent Plugins spec). Session \u2194 PR linking: after a call that addresses GitHub (`gh pr \u2026`, a `curl`/`gh api` REST request, or a GitHub MCP tool) whose output names exactly one pull request, a PostToolUse hook asks the backend one question and injects one instruction \u2014 a session that ran `gh pr create` \u2014 or the GitHub MCP create tool \u2014 links itself unconditionally (opening it is work the session did, and a PR has many sessions), while every other GitHub call, a hand-rolled `curl` POST included, leaves the authorship judgment to the agent, and an org with no GitHub integration is told once that connecting it is what links sessions to shipped code. The hook is stateless apart from one short-lived negative answer (an enabled org with GitHub disconnected, cached 30 minutes per credential, and never inferred from a reply that merely says the feature is off) and degrades to silence on every failure. Two skills complete it: link-pr (link this or a named session by hand \u2014 also the answer for a PR opened by a script or CI helper, which is deliberately not detected) and find-contributing-sessions (scan this machine's local session history for the sessions that wrote a PR's code, rank the candidates by the evidence that matched, and link the ones you approve). The agent records ONE work type for a pull request it links (feat, fix, chore, docs, perf, refactor, other), and only when the PR has none yet \u2014 the first label is permanent, and asking again would cost the link, not just the type. Spec workflows: spec-work guides implementation, spec-check freshly reviews changes, and spec-maintain offers local/cloud bootstrap and reviewable Git or Brain proposals through the shared spec entry point.", - "version": "0.74.0", + "version": "0.75.0", "license": "Apache-2.0", "author": { "name": "XTrace", diff --git a/plugins/memhub/scripts/rulebook_hook.py b/plugins/memhub/scripts/rulebook_hook.py index a9272022..54b8fa9a 100644 --- a/plugins/memhub/scripts/rulebook_hook.py +++ b/plugins/memhub/scripts/rulebook_hook.py @@ -1877,7 +1877,13 @@ def matcher_unsupported(m): _RX_KEYS = ("rx", "not_rx", "body_rx", "cmd_rx", "cmd_not_rx", "path_rx", "path_not_rx", "content_rx", "content_not_rx", "exclude_rx", "converted_rx") -_RX_MAX = 400 +# Length is a bound on what one rule can cost to compile and store, NOT the +# backtracking guard — `_RX_NESTED` below is. A 60-character pattern can stall +# and a 1,500-character alternation of literal paths cannot, so the bound is +# wide enough for the second. It was 400 before 0.75.0: a hook older than that +# drops any rule carrying a longer pattern, which is why the server floors such +# a rule at `min_hook_version` 0.75.0. +_RX_MAX = 2000 # (a+)+, (\d+)+$, (a|a)+, (.*), .*.* — the classic backtracking shapes. A # denylist, not a proof: stdlib `re` has no timeout, and a bounded matcher # (worker + wall clock) is the Phase 2 answer named in §5.1. @@ -1886,7 +1892,7 @@ def matcher_unsupported(m): def rx_ok(pat): """Load-time lint for a pattern that came off the wire (§5.1 fallback): - must compile, stay short, and avoid the nested-quantifier shapes that + must compile, stay under `_RX_MAX`, and avoid the nested-quantifier shapes that backtrack catastrophically. A rejected pattern drops the RULE, never the hook — a server book can advise, it cannot stall a tool call.""" if not isinstance(pat, str) or len(pat) > _RX_MAX or _RX_NESTED.search(pat): diff --git a/tests/rulebook_hook_test.py b/tests/rulebook_hook_test.py index 12c7143f..206ae462 100644 --- a/tests/rulebook_hook_test.py +++ b/tests/rulebook_hook_test.py @@ -2622,6 +2622,19 @@ def pre(session, word): inert is not None and inert.get("mode") == "gate" and not inert.get("_degraded"), str(inert)) + # The length bound is not the backtracking guard, so it is wide enough + # for a long alternation of literals — and still a bound. + long_rx = "^(?:" + "|".join(f"src/pkg{i:03d}/mod\\.py" for i in range(60)) + ")$" + check("regex length: a pattern between the old 400 bound and _RX_MAX loads", + 400 < len(long_rx) <= H._RX_MAX + and H.to_hook_rule(_row("long-rx", {"event": "bash", "command_rx": long_rx})) + is not None, str(len(long_rx))) + check("regex length: a pattern at _RX_MAX loads and one past it drops the rule", + H.rx_ok("a" * H._RX_MAX) and not H.rx_ok("a" * (H._RX_MAX + 1)) + and H.to_hook_rule(_row("over-rx", {"event": "bash", + "command_rx": "a" * (H._RX_MAX + 1)})) + is None) + # An ordering rule needs its arming event first; this one says # `session`, so the session lane has to have run. run("session", {"session_id": "v1", "cwd": repo, diff --git a/tests/rulebook_verify_test.py b/tests/rulebook_verify_test.py index 74e40aed..a791355a 100644 --- a/tests/rulebook_verify_test.py +++ b/tests/rulebook_verify_test.py @@ -85,7 +85,7 @@ def main() -> int: rc == 1 and "SILENT FAIL" in out, out) # --- the silent-drop class -------------------------------------------- - rc, out = run(bash(command_rx="a" * 450), "--fires", "aaa") + rc, out = run(bash(command_rx="a" * 2050), "--fires", "aaa") check("a pattern over the hook's length bound is reported as a LOAD failure", rc == 1 and "LOAD FAIL" in out and "longer than" in out, out) rc, out = run(bash(command_rx="(a+)+$"), "--fires", "aaa") From 8d6d768c7125228213cd412f72eb33761bce7718 Mon Sep 17 00:00:00 2001 From: felix-xtrace Date: Mon, 28 Sep 2026 11:19:12 -0700 Subject: [PATCH 2/3] fix(rulebook): ship the 2000 bound as 0.88.0, and drop a nested pattern instead of tracing back Merged main: 0.75.0 was long taken, staging manifests are gone from the public repo, so the bump is 0.88.0 across the six production manifests and the hook comment names that as the version the server floors a long pattern at. Codex's two findings on the earlier head, both real: - `rx_ok` caught only `re.error`. A pattern nested past the parser's recursion limit ("(" * 500 + "a" + ")" * 500, 1001 chars) raises RecursionError, which the wider bound now lets reach `re.compile`. `rulebook_verify` calls `rx_ok` directly, so a verify of such a candidate tracebacked instead of reporting LOAD FAIL. Caught alongside `re.error`. - The starter generator still truncated repo-derived alternations at the old 400 and reported anything over it as a load failure; the create-rule skill still said over-400 is dropped. Both now say 2000. Co-Authored-By: Claude Fable 5.1 --- plugins/memhub/scripts/rulebook_hook.py | 8 +++++--- plugins/memhub/skills/create-rule/SKILL.md | 2 +- .../skills/start-rulebook/scripts/starter_rulebook.py | 4 ++-- tests/rulebook_hook_test.py | 7 +++++++ tests/rulebook_verify_test.py | 3 +++ 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/plugins/memhub/scripts/rulebook_hook.py b/plugins/memhub/scripts/rulebook_hook.py index d7e7b066..f3f20f5a 100644 --- a/plugins/memhub/scripts/rulebook_hook.py +++ b/plugins/memhub/scripts/rulebook_hook.py @@ -1927,9 +1927,9 @@ def matcher_unsupported(m): # Length is a bound on what one rule can cost to compile and store, NOT the # backtracking guard — `_RX_NESTED` below is. A 60-character pattern can stall # and a 1,500-character alternation of literal paths cannot, so the bound is -# wide enough for the second. It was 400 before 0.75.0: a hook older than that +# wide enough for the second. It was 400 before 0.88.0: a hook older than that # drops any rule carrying a longer pattern, which is why the server floors such -# a rule at `min_hook_version` 0.75.0. +# a rule at `min_hook_version` 0.88.0. _RX_MAX = 2000 # (a+)+, (\d+)+$, (a|a)+, (.*), .*.* — the classic backtracking shapes. A # denylist, not a proof: stdlib `re` has no timeout, and a bounded matcher @@ -1946,7 +1946,9 @@ def rx_ok(pat): return False try: re.compile(pat) - except re.error: + except (re.error, RecursionError): + # A deeply nested pattern trips the parser's recursion limit rather + # than `re.error`; the wider length bound lets it reach `re.compile`. return False return True diff --git a/plugins/memhub/skills/create-rule/SKILL.md b/plugins/memhub/skills/create-rule/SKILL.md index 967dfb22..6565f1e9 100644 --- a/plugins/memhub/skills/create-rule/SKILL.md +++ b/plugins/memhub/skills/create-rule/SKILL.md @@ -499,7 +499,7 @@ raised the subject. It exits non-zero until every case behaves. **Do not file a rule while it exits non-zero, and show the table to the user.** What each line means: -- **LOAD** — whether the hook would load the rule at all. A pattern over 400 +- **LOAD** — whether the hook would load the rule at all. A pattern over 2000 characters, one that does not compile, or one that backtracks is dropped *silently* on every teammate's machine: the rule exists, is active, and never fires. This line is the only warning you get. diff --git a/plugins/memhub/skills/start-rulebook/scripts/starter_rulebook.py b/plugins/memhub/skills/start-rulebook/scripts/starter_rulebook.py index e996c97f..bcaeb95a 100644 --- a/plugins/memhub/skills/start-rulebook/scripts/starter_rulebook.py +++ b/plugins/memhub/skills/start-rulebook/scripts/starter_rulebook.py @@ -49,7 +49,7 @@ # Written without a quantified group: the hook's load lint drops those. # `/usr/bin/git` is the same program: a path in front of it is not a way round every git rule. GIT = CMD + r"(?:sudo\s+)?(?:\S*/)?git\s+(?:-[cC]\s*\S+\s+)?" -RX_MAX = 400 +RX_MAX = 2000 # the hook's `_RX_MAX` since 0.88.0; the server floors a pattern over 400 at that version # Appended to a command pattern to make it a RECEIPT: the command, but not an invocation that exits 0 # having done none of the work — `pytest --version`, `eslint --help`, `pytest --collect-only`. _RAN_NOTHING = (r"\b(?![^|;&]*\s(?:--version|-V|--help|-h|--collect-only|--co|--fixtures|--markers|--setup-plan" @@ -96,7 +96,7 @@ def _alt(parts) -> str: def _fit(prefix: str, extras: list[str], suffix: str = "") -> str: """Append alternatives while the whole pattern stays loadable. The hook - drops a rule whose pattern passes 400 characters, silently, so a long + drops a rule whose pattern passes `RX_MAX` characters, silently, so a long repo-derived list is cut here rather than shipped dead.""" rx = prefix for e in extras: diff --git a/tests/rulebook_hook_test.py b/tests/rulebook_hook_test.py index 206ae462..f489c77a 100644 --- a/tests/rulebook_hook_test.py +++ b/tests/rulebook_hook_test.py @@ -2634,6 +2634,13 @@ def pre(session, word): and H.to_hook_rule(_row("over-rx", {"event": "bash", "command_rx": "a" * (H._RX_MAX + 1)})) is None) + # Under the bound but past the parser's recursion limit: `re.compile` + # raises RecursionError, not re.error. The rule drops; nothing tracebacks. + deep_rx = "(" * 500 + "a" + ")" * 500 + check("regex length: a deeply nested pattern under _RX_MAX drops the rule, no traceback", + len(deep_rx) <= H._RX_MAX and not H.rx_ok(deep_rx) + and H.to_hook_rule(_row("deep-rx", {"event": "bash", "command_rx": deep_rx})) + is None) # An ordering rule needs its arming event first; this one says # `session`, so the session lane has to have run. diff --git a/tests/rulebook_verify_test.py b/tests/rulebook_verify_test.py index 2360eeeb..25ce71c1 100644 --- a/tests/rulebook_verify_test.py +++ b/tests/rulebook_verify_test.py @@ -93,6 +93,9 @@ def main() -> int: rc, out = run(bash(command_rx="(a+)+$"), "--fires", "aaa") check("a catastrophically backtracking pattern is a LOAD failure", rc == 1 and "LOAD FAIL" in out, out) + rc, out = run(bash(command_rx="(" * 500 + "a" + ")" * 500), "--fires", "aaa") + check("a deeply nested pattern under the length bound is a LOAD failure, not a traceback", + rc == 1 and "LOAD FAIL" in out and "Traceback" not in out, out) rc, out = run(bash(command_rx="[unclosed"), "--fires", "x") check("a pattern that does not compile is a LOAD failure", rc == 1 and "LOAD FAIL" in out, out) From 751a98f26c3ddea47e193583835864b44e74b54d Mon Sep 17 00:00:00 2001 From: felix-xtrace Date: Mon, 28 Sep 2026 11:27:57 -0700 Subject: [PATCH 3/3] fix(rulebook): a pattern the parser refuses for any reason drops the rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex, on 8d6d768: `a{` + 500 nines + `}` is 503 characters, passes the nested-quantifier lint, and raises OverflowError from `re.compile`, which `rx_ok` let escape — so `rulebook_verify` tracebacked instead of saying LOAD FAIL. The class is older than the 2000 bound (`a{99999999999}` is 14 characters and does the same), so the guard is now `except Exception`: a pattern off the wire either compiles or the rule drops, whatever the parser objected to. Co-Authored-By: Claude Fable 5.1 --- plugins/memhub/scripts/rulebook_hook.py | 7 ++++--- tests/rulebook_hook_test.py | 6 ++++++ tests/rulebook_verify_test.py | 3 +++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/plugins/memhub/scripts/rulebook_hook.py b/plugins/memhub/scripts/rulebook_hook.py index f3f20f5a..70874475 100644 --- a/plugins/memhub/scripts/rulebook_hook.py +++ b/plugins/memhub/scripts/rulebook_hook.py @@ -1946,9 +1946,10 @@ def rx_ok(pat): return False try: re.compile(pat) - except (re.error, RecursionError): - # A deeply nested pattern trips the parser's recursion limit rather - # than `re.error`; the wider length bound lets it reach `re.compile`. + except Exception: + # Not only `re.error`: a deeply nested pattern raises RecursionError + # and `a{99999999999}` raises OverflowError. Whatever the parser + # objects to, the answer is the same — this pattern does not load. return False return True diff --git a/tests/rulebook_hook_test.py b/tests/rulebook_hook_test.py index f489c77a..2f15d8d4 100644 --- a/tests/rulebook_hook_test.py +++ b/tests/rulebook_hook_test.py @@ -2641,6 +2641,12 @@ def pre(session, word): len(deep_rx) <= H._RX_MAX and not H.rx_ok(deep_rx) and H.to_hook_rule(_row("deep-rx", {"event": "bash", "command_rx": deep_rx})) is None) + # Short, and never caught by the length bound: the parser raises + # OverflowError for a repetition count it cannot hold. + check("regex: a repetition count too large for the parser drops the rule, no traceback", + not H.rx_ok("a{99999999999}") + and H.to_hook_rule(_row("big-rep", {"event": "bash", "command_rx": "a{99999999999}"})) + is None) # An ordering rule needs its arming event first; this one says # `session`, so the session lane has to have run. diff --git a/tests/rulebook_verify_test.py b/tests/rulebook_verify_test.py index 25ce71c1..80d5fbfc 100644 --- a/tests/rulebook_verify_test.py +++ b/tests/rulebook_verify_test.py @@ -96,6 +96,9 @@ def main() -> int: rc, out = run(bash(command_rx="(" * 500 + "a" + ")" * 500), "--fires", "aaa") check("a deeply nested pattern under the length bound is a LOAD failure, not a traceback", rc == 1 and "LOAD FAIL" in out and "Traceback" not in out, out) + rc, out = run(bash(command_rx="a{" + "9" * 500 + "}"), "--fires", "aaa") + check("a repetition count the parser cannot hold is a LOAD failure, not a traceback", + rc == 1 and "LOAD FAIL" in out and "Traceback" not in out, out) rc, out = run(bash(command_rx="[unclosed"), "--fires", "x") check("a pattern that does not compile is a LOAD failure", rc == 1 and "LOAD FAIL" in out, out)