From c575fb09855d621a594ee4b6fc9a1de332681ca6 Mon Sep 17 00:00:00 2001 From: denfry Date: Fri, 25 Sep 2026 09:54:10 +0300 Subject: [PATCH 1/2] fix(hooks): stop the guard from blocking heredocs and unindexed sessions The guard kept denying calls in a session that had already used the index: the first codebase-index command ran before the index existed, so the guard returned early and never recorded it. Session state now lives in the temp directory and is written before the index lookup. Heredoc bodies are stripped before scanning, retries are keyed by search term instead of the full tool input, quoted alternations are parsed whole, and newlines separate commands. --- CHANGELOG.md | 14 +++++++++ src/codebase_index/hooks.py | 62 +++++++++++++++++++++++-------------- tests/test_hooks_guard.py | 37 ++++++++++++++++++++++ 3 files changed, 90 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 434af0b..0fb2334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Fixed + +- The `PreToolUse` guard no longer blocks work it should let through: + - the first `codebase-index` command of a session turns the guard off even when it + is the command that builds the index; before, a repository without an index kept + the guard on for the whole session. Session state now lives in the system temp + directory (`CBX_HOOK_STATE` overrides it) instead of inside the index; + - heredoc bodies (`cat > script.ps1 <<'EOF'`, `python - <`` reads the hook JSON on stdin and prints the hook JSON reply (or nothing). ``CBX_GUARD=0`` disables the guard. +Per-session state lives in the system temp directory (``CBX_HOOK_STATE`` +overrides it), so it exists before the repository has an index. """ from __future__ import annotations @@ -27,21 +31,31 @@ import re import sqlite3 import sys +import tempfile import time from pathlib import Path from typing import Any, Optional INDEX_REL = Path(".claude") / "cache" / "codebase-index" / "index.sqlite" -_STATE_DIR = "hook-sessions" +_STATE_DIR = "codebase-index-hooks" _STATE_TTL_S = 2 * 24 * 3600 # A shell segment that searches file contents: the first command of the segment -# (after `&&`, `||`, `;` or the start), so `ps aux | grep x` is not a repo search. +# (after `&&`, `||`, `;`, a newline or the start), so `ps aux | grep x` is not a +# repo search. Quoted arguments may hold `|`, as in grep "a\|b". _SEARCH_CMD = re.compile( - r"(?:^|&&|\|\||;)\s*(?:cd\s+\S+\s*&&\s*)?" - r"(?Pgit\s+grep|grep|egrep|fgrep|rg|ag|ack|findstr|Select-String)\b(?P[^|;&]*)", + r"(?:^|&&|\|\||;|\n)[ \t]*(?:cd\s+\S+\s*&&\s*)?" + r"(?Pgit\s+grep|grep|egrep|fgrep|rg|ag|ack|findstr|Select-String)\b" + r"""(?P(?:"[^"\n]*"|'[^'\n]*'|[^|;&\n"'])*)""", re.IGNORECASE, ) +# A heredoc body is file content (a script being written, stdin for python), not +# commands; an unterminated one runs to the end of the command. +_HEREDOC = re.compile( + r"""(?[^\n]*)\n""" + r".*?(?:^[ \t]*\2[ \t]*$|\Z)", + re.DOTALL | re.MULTILINE, +) _INDEX_CMD = re.compile(r"(?:^|[\s;&|/\\\"'])(?:codebase-index|cbx)(?:\.exe|\.ps1)?\s+\w") # Searches over prose, logs and config are what grep is for; leave them alone. _NON_CODE = re.compile( @@ -132,15 +146,12 @@ def guard(payload: dict) -> Optional[dict]: tool_input = payload.get("tool_input") or {} if tool not in ("Grep", "Bash"): return None - root = find_index_root(_cwd(payload)) - if root is None: - return None - session = str(payload.get("session_id") or "default") - state = _State(root, session) + state = _State(str(payload.get("session_id") or "default")) if tool == "Bash": - command = str(tool_input.get("command") or "") + command = _HEREDOC.sub(lambda m: "<<" + m.group("rest"), str(tool_input.get("command") or "")) if _INDEX_CMD.search(command): + # Checked before the index exists: the first search is what builds it. state.mark_used() return None pattern = _shell_search_pattern(command) @@ -151,11 +162,11 @@ def guard(payload: dict) -> Optional[dict]: return None pattern = str(tool_input.get("pattern") or "") - if state.used: + if state.used or find_index_root(_cwd(payload)) is None: return None - fingerprint = hashlib.sha1( - (tool + json.dumps(tool_input, sort_keys=True, default=str)).encode("utf-8") - ).hexdigest() + # Keyed by the search term, not the whole call: a retry rarely repeats the + # command byte for byte (a new description, another pipe, a regenerated script). + fingerprint = hashlib.sha1(_term(pattern).lower().encode("utf-8")).hexdigest() if state.was_denied(fingerprint): return None # the retry after a nudge: the agent decided grep is right state.deny(fingerprint) @@ -190,26 +201,31 @@ def _grep_targets_non_code(tool_input: dict) -> bool: return bool(_NON_CODE.search(target)) +def _term(pattern: str) -> str: + """A regex search pattern as plain query words.""" + return " ".join(re.sub(r"""[\\^$()\[\]{}|*+?"']""", " ", pattern).split())[:80] + + def _reason(pattern: str) -> str: - term = re.sub(r"[\\^$()\[\]{}|*+?]", " ", pattern).strip() or "" - term = " ".join(term.split())[:80] + term = _term(pattern) or "" return ( "codebase-index: this repository is indexed, and the index answers code " "searches with ranked, numbered lines in fewer tokens than grep. Run it first:\n" f' codebase-index search "{term}" --compact # where / how\n' ' codebase-index refs "Owner.member" --compact # every call site, with callers\n' ' codebase-index symbol "Name" # a definition\n' - "If the index does not answer, repeat this exact call and it will run. Once any " - "codebase-index command has run in this session, searches are not intercepted." + "If the index does not answer, search for the same term again and it will run. " + "Once any codebase-index command has run in this session, searches are not " + "intercepted." ) class _State: """Per-session guard state: whether the index was used, which calls were nudged.""" - def __init__(self, root: Path, session: str) -> None: + def __init__(self, session: str) -> None: safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session)[:80] or "default" - self.dir = root / INDEX_REL.parent / _STATE_DIR + self.dir = Path(os.environ.get("CBX_HOOK_STATE") or Path(tempfile.gettempdir()) / _STATE_DIR) self.path = self.dir / f"{safe}.json" self.data: dict[str, Any] = {"used": False, "denied": []} try: diff --git a/tests/test_hooks_guard.py b/tests/test_hooks_guard.py index 86365a8..c2265d9 100644 --- a/tests/test_hooks_guard.py +++ b/tests/test_hooks_guard.py @@ -11,6 +11,11 @@ from codebase_index import hooks, scaffold +@pytest.fixture(autouse=True) +def _state_dir(tmp_path: Path, monkeypatch): + monkeypatch.setenv("CBX_HOOK_STATE", str(tmp_path / "hook-state")) + + @pytest.fixture def repo(tmp_path: Path) -> Path: db = tmp_path / "repo" / hooks.INDEX_REL @@ -68,6 +73,38 @@ def test_non_code_and_non_search_shell_commands_pass(repo: Path, command: str): assert _call(repo, "Bash", command=command) is None +def test_the_retry_is_matched_by_search_term_not_by_the_whole_call(repo: Path): + assert _denied(_call(repo, "Bash", command="grep -rn TownService src", + description="Find the service")) + assert _call(repo, "Bash", command="cd src && grep -rn TownService . | head", + description="Find it again") is None + + +def test_an_index_command_that_builds_the_index_turns_the_guard_off(tmp_path: Path, repo: Path): + # The first `codebase-index search` in a fresh repository builds the index, so the + # guard sees that call before any index exists. + fresh = tmp_path / "fresh" + (fresh / "src").mkdir(parents=True) + assert _call(fresh, "Bash", command='codebase-index search "x" --compact') is None + assert _call(repo, "Grep", pattern="anything") is None + + +@pytest.mark.parametrize("command", [ + # A heredoc body is file content, not commands. + "cat > /tmp/close.ps1 <<'EOF'\n$log = Get-Content x; Select-String \"BUILD FAILED\" $log\nEOF\n" + "echo done", + "python - < Date: Fri, 25 Sep 2026 09:56:41 +0300 Subject: [PATCH 2/2] =?UTF-8?q?release:=20v2.1.2=20=E2=80=94=20hook=20guar?= =?UTF-8?q?d=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version bump with every mirror synced (plugin.json, requirements.lock, skill stamps) and the 2.1.2 release notes. --- .claude-plugin/plugin.json | 2 +- .claude/skills/codebase-index/.skill_version | 2 +- .codex/skills/codebase-index/.skill_version | 2 +- .opencode/skills/codebase-index/.skill_version | 2 +- CHANGELOG.md | 5 ++++- docs/installer.md | 2 +- requirements.lock | 2 +- src/codebase_index/__init__.py | 2 +- 8 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4fe56c7..42a4d68 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "name": "codebase-index", "displayName": "Codebase Index", "description": "Give Claude a precise local map of your codebase: find implementations, trace behavior, and predict change impact with file-line evidence.", - "version": "2.1.1", + "version": "2.1.2", "author": { "name": "codebase-index contributors" }, diff --git a/.claude/skills/codebase-index/.skill_version b/.claude/skills/codebase-index/.skill_version index 3e3c2f1..eca07e4 100644 --- a/.claude/skills/codebase-index/.skill_version +++ b/.claude/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.1 +2.1.2 diff --git a/.codex/skills/codebase-index/.skill_version b/.codex/skills/codebase-index/.skill_version index 3e3c2f1..eca07e4 100644 --- a/.codex/skills/codebase-index/.skill_version +++ b/.codex/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.1 +2.1.2 diff --git a/.opencode/skills/codebase-index/.skill_version b/.opencode/skills/codebase-index/.skill_version index 3e3c2f1..eca07e4 100644 --- a/.opencode/skills/codebase-index/.skill_version +++ b/.opencode/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.1 +2.1.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb2334..19de51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [2.1.2] - 2026-09-25 + ### Fixed - The `PreToolUse` guard no longer blocks work it should let through: @@ -900,7 +902,8 @@ Pooled over 305 queries (Python, Java, TypeScript), v1.8.0 → 1.9.0: - Hooks example + `watch` mode for keeping the index fresh without blocking the edit loop (M8). - `doctor`, `stats`, `clean` diagnostics/maintenance commands. -[Unreleased]: https://github.com/denfry/codebase-index/compare/v2.1.1...HEAD +[Unreleased]: https://github.com/denfry/codebase-index/compare/v2.1.2...HEAD +[2.1.2]: https://github.com/denfry/codebase-index/compare/v2.1.1...v2.1.2 [2.1.1]: https://github.com/denfry/codebase-index/compare/v2.1.0...v2.1.1 [2.1.0]: https://github.com/denfry/codebase-index/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/denfry/codebase-index/compare/v1.9.0...v2.0.0 diff --git a/docs/installer.md b/docs/installer.md index 129ed9b..1521c11 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -110,7 +110,7 @@ pwsh ./install.ps1 -Target claude -InstallDir "D:\skills\codebase-index" **Pin to a branch or tag** (reproducibility and safety): ```sh -sh install.sh --branch v2.1.1 +sh install.sh --branch v2.1.2 ``` --- diff --git a/requirements.lock b/requirements.lock index d0f2b53..efd85ea 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,3 +1,3 @@ -codebase-index @ https://github.com/denfry/codebase-index/archive/refs/tags/v2.1.1.tar.gz +codebase-index @ https://github.com/denfry/codebase-index/archive/refs/tags/v2.1.2.tar.gz tree-sitter==0.25.2 tree-sitter-language-pack==1.8.1 diff --git a/src/codebase_index/__init__.py b/src/codebase_index/__init__.py index 593777e..8d5fd5e 100644 --- a/src/codebase_index/__init__.py +++ b/src/codebase_index/__init__.py @@ -4,4 +4,4 @@ See docs/ARCHITECTURE.md for the module map. """ -__version__ = "2.1.1" +__version__ = "2.1.2"