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 434af0b..19de51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ 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: + - 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 - <