diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 42a4d68..f5cb7ee 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.2", + "version": "2.1.3", "author": { "name": "codebase-index contributors" }, diff --git a/.claude/skills/codebase-index/.skill_version b/.claude/skills/codebase-index/.skill_version index eca07e4..ac2cdeb 100644 --- a/.claude/skills/codebase-index/.skill_version +++ b/.claude/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.2 +2.1.3 diff --git a/.codex/skills/codebase-index/.skill_version b/.codex/skills/codebase-index/.skill_version index eca07e4..ac2cdeb 100644 --- a/.codex/skills/codebase-index/.skill_version +++ b/.codex/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.2 +2.1.3 diff --git a/.opencode/skills/codebase-index/.skill_version b/.opencode/skills/codebase-index/.skill_version index eca07e4..ac2cdeb 100644 --- a/.opencode/skills/codebase-index/.skill_version +++ b/.opencode/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.2 +2.1.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index e85a3f5..9d6fd1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,25 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [2.1.3] - 2026-11-02 + ### Fixed - Removing files from a large index no longer takes hours: `chunks.symbol_id` and `symbols.parent_id` are now indexed, so a deleted symbol no longer costs a full scan of both tables. Existing indexes gain the two indexes the next time they are opened, with no rebuild. +- The `PreToolUse` guard now covers Claude Code's PowerShell tool: + - a `codebase-index` command run from PowerShell turns the guard off for the session, + and `Select-String` or `rg` code searches there are guarded like their Bash + equivalents. A `Select-String` that reads piped input is left alone, and here-string + bodies (`@'...'@`) are not scanned; + - `install-hooks` refreshes the matcher of an installed guard. Run it again after + upgrading; the plugin's hooks pick this up on their own. +- The guard no longer intercepts searches that cannot be answered by the index: those + over `.git` internals, binaries (`.exe`, `.dll`, `.jar`, `.sqlite` and the like), or + absolute paths outside the indexed repository. Nor does it intercept anything in a + directory without an index. ## [2.1.2] - 2026-09-25 @@ -909,7 +922,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.2...HEAD +[Unreleased]: https://github.com/denfry/codebase-index/compare/v2.1.3...HEAD +[2.1.3]: https://github.com/denfry/codebase-index/compare/v2.1.2...v2.1.3 [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 diff --git a/docs/installer.md b/docs/installer.md index 1521c11..97656a0 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.2 +sh install.sh --branch v2.1.3 ``` --- diff --git a/hooks/hooks.json b/hooks/hooks.json index bd59d21..4ad504d 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -17,7 +17,7 @@ ], "PreToolUse": [ { - "matcher": "Grep|Bash", + "matcher": "Grep|Bash|PowerShell", "hooks": [ { "type": "command", diff --git a/requirements.lock b/requirements.lock index efd85ea..acc2814 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,3 +1,3 @@ -codebase-index @ https://github.com/denfry/codebase-index/archive/refs/tags/v2.1.2.tar.gz +codebase-index @ https://github.com/denfry/codebase-index/archive/refs/tags/v2.1.3.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 8d5fd5e..d6ac834 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.2" +__version__ = "2.1.3" diff --git a/src/codebase_index/cli.py b/src/codebase_index/cli.py index 85170f4..0680ae0 100644 --- a/src/codebase_index/cli.py +++ b/src/codebase_index/cli.py @@ -1074,10 +1074,11 @@ def install_hooks( ) -> None: """Make Claude Code search through the index by default. - Adds a SessionStart note and a PreToolUse guard on Grep/Bash: the first code - search of a session, before any codebase-index command, is sent back with the - index command to run instead; repeating it lets it through. Both do nothing in - a directory without an index. Other hooks in the settings file are kept. + Adds a SessionStart note and a PreToolUse guard on Grep, Bash and PowerShell: the + first code search of a session, before any codebase-index command, is sent back + with the index command to run instead; repeating it lets it through. Both do + nothing in a directory without an index. Other hooks in the settings file are + kept. Run it again after an upgrade to refresh an installed guard. """ from .config import find_root from . import scaffold @@ -1092,7 +1093,7 @@ def install_hooks( verb = "removed from" else: changed = scaffold.install_guard_hooks(path) - verb = "added to" + verb = "added to or refreshed in" if changed: typer.echo(f"[install-hooks] {', '.join(changed)} {verb} {path}") else: diff --git a/src/codebase_index/hooks.py b/src/codebase_index/hooks.py index 9c2ef4d..179730b 100644 --- a/src/codebase_index/hooks.py +++ b/src/codebase_index/hooks.py @@ -4,17 +4,17 @@ - ``session-start`` adds one short note to the session context: this repository is indexed, and which commands answer code questions. -- ``guard`` (PreToolUse on Grep and Bash) intercepts a code search the first time +- ``guard`` (PreToolUse on Grep, Bash and PowerShell) intercepts a code search the first time in a session, before any ``codebase-index`` command has run, and names the index command to use instead. It is a nudge, not a wall: searching for the same term again lets it through, the first ``codebase-index`` command in the session turns the guard off (even one that has yet to build the index), and - searches over docs, logs or config, or inside heredoc bodies, are never - intercepted. + searches over docs, logs or config, or inside heredoc and here-string bodies, + are never intercepted. Measured on a 5.9k-file monorepo, an agent spent 19% fewer tokens with the index than with grep (tests/eval/results/2026-09-24-agent-pilot.md), but agents reach -for Grep out of habit. The guard runs on every Bash call, so this module imports +for Grep out of habit. The guard runs on every shell call, so this module imports only the standard library and does no index work beyond one small SQLite read. Entry point: ``codebase-index-hook `` reads the hook JSON on stdin and @@ -39,6 +39,7 @@ INDEX_REL = Path(".claude") / "cache" / "codebase-index" / "index.sqlite" _STATE_DIR = "codebase-index-hooks" _STATE_TTL_S = 2 * 24 * 3600 +SHELL_TOOLS = ("Bash", "PowerShell") # A shell segment that searches file contents: the first command of the segment # (after `&&`, `||`, `;`, a newline or the start), so `ps aux | grep x` is not a @@ -56,13 +57,22 @@ r".*?(?:^[ \t]*\2[ \t]*$|\Z)", re.DOTALL | re.MULTILINE, ) +# PowerShell's heredoc: the closing `'@` or `"@` starts its own line. +_HERE_STRING = re.compile(r"""@(['"])[ \t]*\r?\n.*?^\1@""", 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. +# Searches over prose, logs, config, binaries and git internals are what grep is for; +# leave them alone. _NON_CODE = re.compile( - r"\.(?:md|mdx|rst|txt|log|ya?ml|json|toml|ini|cfg|conf|csv|xml|properties|lock|env|html?)\b" - r"|--include=\*?\.(?:md|txt|log|ya?ml|json|toml)", + r"\.(?:md|mdx|rst|txt|log|ya?ml|json|toml|ini|cfg|conf|csv|xml|properties|lock|env|html?" + r"|exe|dll|so|jar|class|bin|sqlite|db)\b" + r"|--include=\*?\.(?:md|txt|log|ya?ml|json|toml)" + r"|(?:^|[\s'\"/\\])\.git[\\/]", re.IGNORECASE, ) +# Shell words of a search's arguments: a quoted string or a bare word. +_WORD = re.compile(r""""([^"]*)"|'([^']*)'|(\S+)""") +# An absolute path: a drive (C:\, C:/), an MSYS drive or POSIX root (/c/, /), or home. +_ABS_PATH = re.compile(r"^(?:[A-Za-z]:[\\/]|/|~(?:[\\/]|$)|\$HOME\b|\$\{HOME\})") _NON_CODE_TYPES = {"md", "markdown", "txt", "log", "yaml", "yml", "json", "toml", "xml", "csv", "ini", "config"} @@ -144,25 +154,34 @@ def guard(payload: dict) -> Optional[dict]: return None tool = payload.get("tool_name") or "" tool_input = payload.get("tool_input") or {} - if tool not in ("Grep", "Bash"): + if tool != "Grep" and tool not in SHELL_TOOLS: return None state = _State(str(payload.get("session_id") or "default")) - if tool == "Bash": + if tool in SHELL_TOOLS: command = _HEREDOC.sub(lambda m: "<<" + m.group("rest"), str(tool_input.get("command") or "")) + if tool == "PowerShell": + command = _HERE_STRING.sub("''", command) 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) + root = find_index_root(_cwd(payload)) + if root is None: + return None + pattern = _shell_search_pattern(command, root) if pattern is None: return None else: - if _grep_targets_non_code(tool_input): + root = find_index_root(_cwd(payload)) + if root is None or _grep_targets_non_code(tool_input): + return None + target = str(tool_input.get("path") or "") + if target and _outside_repo([target], root): return None pattern = str(tool_input.get("pattern") or "") - if state.used or find_index_root(_cwd(payload)) is None: + if state.used: return None # 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). @@ -179,20 +198,63 @@ def guard(payload: dict) -> Optional[dict]: } -def _shell_search_pattern(command: str) -> Optional[str]: +def _shell_search_pattern(command: str, root: Path) -> Optional[str]: """The search term of a content search in `command`, or None if there is none.""" for match in _SEARCH_CMD.finditer(command): args = match.group("args") if _NON_CODE.search(args): continue - quoted = re.search(r"""(['"])(.+?)\1""", args) - if quoted: - return quoted.group(2) - words = [w for w in args.split() if not w.startswith("-")] - return words[0] if words else "" + words = [next(g for g in m.groups() if g is not None) for m in _WORD.finditer(args)] + if match.group("cmd").lower() == "select-string": + pattern, paths = _select_string_terms(words) + else: + positional = [w for w in words if not w.startswith("-") and not w.isdigit()] + # The first positional word is the pattern, the rest are the paths searched. + paths = positional[1:] + quoted = re.search(r"""(['"])(.+?)\1""", args) + pattern = quoted.group(2) if quoted else (positional[0] if positional else "") + if not _outside_repo(paths, root): + return pattern return None +def _select_string_terms(words: list[str]) -> tuple[str, list[str]]: + """The pattern and paths of a Select-String call, named or positional.""" + pattern: list[str] = [] + paths: list[str] = [] + positional: list[str] = [] + target = None + for word in words: + if target is not None: + target.extend(word.split(",") if target is paths else [word]) + target = None + elif word.lower() in ("-path", "-literalpath", "-lp"): + target = paths + elif word.lower() == "-pattern": + target = pattern + elif not word.startswith("-") and not word.isdigit(): + positional.append(word) + if not pattern and positional: + pattern.append(positional.pop(0)) + return (pattern[0] if pattern else ""), paths or positional + + +def _outside_repo(paths: list[str], root: Path) -> bool: + """True when every path is absolute and none lies inside the indexed repository.""" + if not paths or not all(_ABS_PATH.match(p) for p in paths): + return False + base = os.path.normcase(os.path.abspath(root)) + for raw in paths: + path = re.sub(r"^(?:~|\$HOME\b|\$\{HOME\})", lambda _m: str(Path.home()), raw) + msys = re.match(r"^/([a-zA-Z])(/.*)?$", path) + if os.name == "nt" and msys: + path = f"{msys.group(1)}:{msys.group(2) or '/'}" + full = os.path.normcase(os.path.abspath(path)) + if full == base or full.startswith(base.rstrip(os.sep) + os.sep): + return False + return True + + def _grep_targets_non_code(tool_input: dict) -> bool: kind = str(tool_input.get("type") or "").lower() if kind in _NON_CODE_TYPES: diff --git a/src/codebase_index/scaffold.py b/src/codebase_index/scaffold.py index 4862f14..d7b2d7c 100644 --- a/src/codebase_index/scaffold.py +++ b/src/codebase_index/scaffold.py @@ -292,7 +292,7 @@ def merge_hook_settings(root: Path) -> bool: "timeout": 5}], }, "PreToolUse": { - "matcher": "Grep|Bash", + "matcher": "Grep|Bash|PowerShell", "hooks": [{"type": "command", "command": "codebase-index-hook guard", "timeout": 5}], }, } @@ -305,7 +305,9 @@ def _is_guard_entry(entry: dict) -> bool: def install_guard_hooks(settings_path: Path) -> list[str]: """Merge the index-first hooks into a Claude Code settings file. - Idempotent, and leaves every other hook in place. Returns the events added. + Idempotent, and leaves every other hook in place. An installed guard gets the + current matcher, so upgrading only takes running this again. Returns the events + added or refreshed. """ settings: dict = {} if settings_path.exists(): @@ -314,9 +316,14 @@ def install_guard_hooks(settings_path: Path) -> list[str]: added = [] for event, entry in GUARD_HOOKS.items(): entries = hooks.setdefault(event, []) - if not any(_is_guard_entry(e) for e in entries): + installed = [e for e in entries if _is_guard_entry(e)] + if not installed: entries.append(json.loads(json.dumps(entry))) added.append(event) + elif "matcher" in entry and any(e.get("matcher") != entry["matcher"] for e in installed): + for existing in installed: + existing["matcher"] = entry["matcher"] + added.append(event) if added: settings_path.parent.mkdir(parents=True, exist_ok=True) settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") diff --git a/tests/test_hooks_guard.py b/tests/test_hooks_guard.py index c2265d9..498b1c1 100644 --- a/tests/test_hooks_guard.py +++ b/tests/test_hooks_guard.py @@ -114,6 +114,80 @@ def test_code_searches_in_the_shell_are_guarded(repo: Path, command: str): assert _denied(_call(repo, "Bash", command=command)) +@pytest.mark.parametrize("command", [ + "grep -qx '.claude/' .git/info/exclude", + "grep -aoE 'effortLevel' /c/Users/me/.local/bin/claude.exe", + "grep -rn 'def guard' ~/other-project/src", +]) +def test_searches_over_git_internals_binaries_or_outside_the_repo_pass(repo: Path, command: str): + assert _call(repo, "Bash", command=command) is None + + +def test_a_search_outside_the_repo_passes_and_one_inside_it_is_guarded(repo: Path, tmp_path: Path): + elsewhere = (tmp_path / "elsewhere").as_posix() + assert _call(repo, "Bash", command=f"grep -rn -A 3 TownService {elsewhere}") is None + assert _call(repo, "Grep", pattern="TownService", path=elsewhere) is None + assert _denied(_call(repo, "Bash", command=f"grep -rn TownService {(repo / 'src').as_posix()}")) + assert _denied(_call(repo, "Grep", pattern="Other", path=str(repo / "src"))) + # A pattern that looks like a path does not count as one. + assert _denied(_call(repo, "Bash", command='grep -rn "/api/v1" src')) + + +def test_an_index_command_in_powershell_turns_the_guard_off(repo: Path): + assert _call(repo, "PowerShell", command='codebase-index search "x" --compact; git status') is None + assert _call(repo, "Grep", pattern="anything") is None + + +@pytest.mark.parametrize("command", [ + "Select-String -Pattern TownService -Path src\\*.java", + "Select-String -Path src\\*.java -Pattern 'TownService'", + 'git status; Select-String "TownService" src\\*.java', + "rg -n TownService src", +]) +def test_code_searches_in_powershell_are_guarded(repo: Path, command: str): + reply = _call(repo, "PowerShell", command=command) + assert _denied(reply) + assert 'search "TownService"' in reply["hookSpecificOutput"]["permissionDecisionReason"] + + +@pytest.mark.parametrize("command", [ + "Get-Content build.txt | Select-String BUILD", + "Select-String -Path README.md -Pattern install", + "git commit -m @'\nfix: stop\n\ngrep -rn TownService src\n'@", + 'Set-Content x.ps1 -Value @"\nSelect-String Town src\n"@\ngit status', +]) +def test_powershell_filters_prose_and_here_strings_pass(repo: Path, command: str): + assert _call(repo, "PowerShell", command=command) is None + + +def test_select_string_outside_the_repo_passes_whichever_way_it_is_named(repo: Path, tmp_path: Path): + elsewhere = str(tmp_path / "elsewhere" / "*.cs") + assert _call(repo, "PowerShell", command=f"Select-String -Path '{elsewhere}' -Pattern Town") is None + assert _call(repo, "PowerShell", command=f"Select-String -Pattern Town -LiteralPath '{elsewhere}'") is None + inside = str(repo / "src" / "*.cs") + assert _denied(_call(repo, "PowerShell", command=f"Select-String -Path '{inside}' -Pattern Town")) + + +def test_install_refreshes_the_matcher_of_an_older_guard(tmp_path: Path): + path = tmp_path / "settings.json" + scaffold.install_guard_hooks(path) + data = json.loads(path.read_text(encoding="utf-8")) + data["hooks"]["PreToolUse"][0]["matcher"] = "Grep|Bash" + path.write_text(json.dumps(data), encoding="utf-8") + + assert scaffold.install_guard_hooks(path) == ["PreToolUse"] + data = json.loads(path.read_text(encoding="utf-8")) + assert data["hooks"]["PreToolUse"][0]["matcher"] == scaffold.GUARD_HOOKS["PreToolUse"]["matcher"] + assert scaffold.install_guard_hooks(path) == [] + + +def test_every_guard_matcher_covers_the_tools_the_guard_reads(): + plugin = json.loads(Path("hooks/hooks.json").read_text(encoding="utf-8")) + for matcher in (scaffold.GUARD_HOOKS["PreToolUse"]["matcher"], + plugin["hooks"]["PreToolUse"][0]["matcher"]): + assert set(matcher.split("|")) == {"Grep", *hooks.SHELL_TOOLS} + + def test_grep_over_docs_or_config_passes(repo: Path): assert _call(repo, "Grep", pattern="x", glob="**/*.yml") is None assert _call(repo, "Grep", pattern="x", type="md") is None