Created
August 18, 2026 07:28
-
-
Save krisanalfa/c205ba5ac226965696f8722b4f4f3e8e to your computer and use it in GitHub Desktop.
Force Claude Code to Use Serena
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """PreToolUse hook (matcher: Bash). Denies grep/sed/cat/rg/awk/head/tail/ls/find | |
| (and the same tools laundered through `rtk <tool>` / `rtk proxy <tool>`) when | |
| they target a file or directory Serena actually covers in this project — per | |
| this repo's CLAUDE.md rule to use Serena MCP tools instead. | |
| "Covered by Serena" is derived from two sources, not a hardcoded list: | |
| 1. git's ignore rules (.gitignore, nested .gitignore files, git's own | |
| built-ins) — Serena's project.yml sets `ignore_all_files_in_gitignore: | |
| true`, so anything git ignores (node_modules, python/service/.venv, | |
| dist, etc.) is invisible to Serena and safe for raw shell tools. | |
| 2. .serena/project.yml's `languages:` list (mapped to file extensions) and | |
| its `ignored_paths:` glob list — the actual set of file types Serena's | |
| LSP backend indexes in *this* project, and any additional excludes | |
| configured there. | |
| A target only gets denied if it exists, is not git-ignored, is not covered | |
| by project.yml's `ignored_paths`, and (for files) has an extension Serena | |
| indexes, or (for directories) isn't itself a VCS-internal dir. Anything | |
| ambiguous — parse failure, git/config lookup failure, a path outside the | |
| repo, a token that isn't an existing path at all (patterns, flag values, | |
| counts) — fails OPEN. This hook only ever adds a deny; it never blocks a | |
| command it doesn't confidently recognize as targeting Serena-covered code. | |
| """ | |
| import fnmatch | |
| import json | |
| import os | |
| import re | |
| import shlex | |
| import subprocess | |
| import sys | |
| BANNED_TOOLS = {"grep", "sed", "cat", "rg", "awk", "head", "tail", "ls", "find"} | |
| OPERATORS = {";", "&", "|", "&&", "||", "(", ")"} | |
| # Fallback extension map for Serena's `languages:` values. Only entries for | |
| # languages actually configured in .serena/project.yml matter today | |
| # (typescript, python, markdown, json); the rest is here so this keeps | |
| # working correctly if project.yml's language list ever grows. | |
| LANG_EXTENSIONS = { | |
| "typescript": ["ts", "tsx", "js", "jsx", "mjs", "cjs"], | |
| "typescript_vts": ["ts", "tsx", "js", "jsx", "mjs", "cjs"], | |
| "python": ["py"], | |
| "python_jedi": ["py"], | |
| "python_pyrefly": ["py"], | |
| "python_ty": ["py"], | |
| "markdown": ["md", "mdx"], | |
| "json": ["json"], | |
| "yaml": ["yaml", "yml"], | |
| "toml": ["toml"], | |
| "html": ["html", "htm"], | |
| "css": ["css"], | |
| "scss": ["scss", "sass"], | |
| "go": ["go"], | |
| "rust": ["rs"], | |
| "java": ["java"], | |
| "kotlin": ["kt", "kts"], | |
| "cpp": ["cpp", "cc", "cxx", "hpp", "hxx", "h"], | |
| "cpp_ccls": ["cpp", "cc", "cxx", "hpp", "hxx", "h"], | |
| "ruby": ["rb"], | |
| "ruby_solargraph": ["rb"], | |
| "php": ["php"], | |
| "php_phpactor": ["php"], | |
| "php_phpantom": ["php"], | |
| "bash": ["sh", "bash"], | |
| "csharp": ["cs"], | |
| "csharp_omnisharp": ["cs"], | |
| "swift": ["swift"], | |
| "lua": ["lua"], | |
| "luau": ["luau"], | |
| "elixir": ["ex", "exs"], | |
| "elm": ["elm"], | |
| "erlang": ["erl"], | |
| "dart": ["dart"], | |
| "terraform": ["tf"], | |
| "vue": ["vue"], | |
| "svelte": ["svelte"], | |
| "perl": ["pl", "pm"], | |
| "r": ["r"], | |
| "scala": ["scala"], | |
| "zig": ["zig"], | |
| } | |
| REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| def read_command(): | |
| try: | |
| payload = json.load(sys.stdin) | |
| except (ValueError, json.JSONDecodeError): | |
| return None, None | |
| cmd = payload.get("tool_input", {}).get("command") or "" | |
| cwd = payload.get("cwd") or os.getcwd() | |
| return (cmd or None), cwd | |
| def load_serena_config(): | |
| """Best-effort, dependency-free read of .serena/project.yml's | |
| `languages:` and `ignored_paths:` lists. Returns (extensions_set, | |
| ignored_glob_patterns). Fails open (empty sets) on any parse trouble — | |
| an empty extensions set just means nothing gets denied on extension | |
| grounds, which is the safe direction for a hook that only adds denies. | |
| """ | |
| path = os.path.join(REPO_ROOT, ".serena", "project.yml") | |
| extensions = set() | |
| ignored_globs = [] | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| lines = f.readlines() | |
| except OSError: | |
| return extensions, ignored_globs | |
| def collect_list(start_idx): | |
| items = [] | |
| for line in lines[start_idx + 1 :]: | |
| stripped = line.strip() | |
| if not stripped: | |
| continue | |
| if not line.startswith((" ", "-", "\t")): | |
| break | |
| m = re.match(r'^\s*-\s*"?([^"#]+?)"?\s*(#.*)?$', line) | |
| if not m: | |
| break | |
| items.append(m.group(1).strip()) | |
| return items | |
| for i, line in enumerate(lines): | |
| key = line.strip() | |
| if key == "languages:": | |
| for lang in collect_list(i): | |
| extensions.update(LANG_EXTENSIONS.get(lang, [lang])) | |
| elif key == "ignored_paths:": | |
| ignored_globs.extend(collect_list(i)) | |
| return extensions, ignored_globs | |
| def tokenize(cmd): | |
| try: | |
| lexer = shlex.shlex(cmd, posix=True, punctuation_chars=";&|()") | |
| lexer.whitespace_split = True | |
| return list(lexer) | |
| except ValueError: | |
| return None | |
| def git_ignored(cwd, token): | |
| try: | |
| result = subprocess.run( | |
| ["git", "check-ignore", "-q", "--", token], | |
| cwd=cwd, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| timeout=2, | |
| ) | |
| except (OSError, subprocess.TimeoutExpired): | |
| return None # ambiguous -> caller fails open | |
| if result.returncode == 0: | |
| return True | |
| if result.returncode == 1: | |
| return False | |
| return None # not a git repo / other error -> ambiguous | |
| def serena_ignored(rel_path, ignored_globs): | |
| for pattern in ignored_globs: | |
| if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch( | |
| rel_path, pattern.rstrip("/") + "/*" | |
| ): | |
| return True | |
| return False | |
| def is_serena_target(token, cwd, extensions, ignored_globs): | |
| if not token or token.startswith("-"): | |
| return False | |
| abs_path = token if os.path.isabs(token) else os.path.normpath(os.path.join(cwd, token)) | |
| if not os.path.exists(abs_path): | |
| return False # not a real path (pattern, flag value, count, ...) -> fail open | |
| try: | |
| rel_to_repo = os.path.relpath(abs_path, REPO_ROOT) | |
| except ValueError: | |
| return False # different drive on Windows-ish edge case -> fail open | |
| if rel_to_repo.startswith(".."): | |
| return False # outside the repo entirely -> not Serena's domain | |
| # VCS internals are never a Serena target, ignored-by-git or not. | |
| first_component = rel_to_repo.split(os.sep, 1)[0] | |
| if first_component == ".git": | |
| return False | |
| ignored = git_ignored(cwd, token) | |
| if ignored is None: | |
| return False # ambiguous -> fail open | |
| if ignored: | |
| return False # git-ignored -> outside Serena's coverage | |
| if serena_ignored(rel_to_repo, ignored_globs): | |
| return False # additionally excluded via project.yml ignored_paths | |
| if os.path.isdir(abs_path): | |
| return True # tracked, non-ignored directory -> Serena's list_dir/find_file | |
| ext = os.path.splitext(abs_path)[1].lstrip(".") | |
| return ext in extensions | |
| def find_invocation(tokens, i): | |
| """If tokens[i] starts a banned-tool invocation (directly, via `rtk | |
| <tool>`, or `rtk proxy <tool>`), return the index of the first argument | |
| token. Otherwise return None. | |
| """ | |
| tok = tokens[i] | |
| if tok in BANNED_TOOLS: | |
| return i + 1 | |
| if tok == "rtk": | |
| if i + 1 < len(tokens) and tokens[i + 1] in BANNED_TOOLS: | |
| return i + 2 | |
| if ( | |
| i + 2 < len(tokens) | |
| and tokens[i + 1] == "proxy" | |
| and tokens[i + 2] in BANNED_TOOLS | |
| ): | |
| return i + 3 | |
| return None | |
| def command_targets_serena(cmd, cwd, extensions, ignored_globs): | |
| tokens = tokenize(cmd) | |
| if not tokens: | |
| return False | |
| at_command_start = True | |
| i = 0 | |
| while i < len(tokens): | |
| tok = tokens[i] | |
| if tok in OPERATORS: | |
| at_command_start = True | |
| i += 1 | |
| continue | |
| if at_command_start: | |
| args_start = find_invocation(tokens, i) | |
| if args_start is not None: | |
| j = args_start | |
| while j < len(tokens) and tokens[j] not in OPERATORS: | |
| if is_serena_target(tokens[j], cwd, extensions, ignored_globs): | |
| return True | |
| j += 1 | |
| i = j | |
| at_command_start = False | |
| continue | |
| at_command_start = False | |
| i += 1 | |
| return False | |
| def main(): | |
| cmd, cwd = read_command() | |
| if not cmd: | |
| return | |
| # Cheap pre-filter before any tokenizing/subprocess work. | |
| if not re.search(r"\b(grep|sed|cat|rg|awk|head|tail|ls|find|rtk)\b", cmd): | |
| return | |
| extensions, ignored_globs = load_serena_config() | |
| if not command_targets_serena(cmd, cwd, extensions, ignored_globs): | |
| return | |
| reason = ( | |
| "grep/sed/cat/rg/awk/head/tail/ls/find (including via `rtk <tool>` / " | |
| "`rtk proxy <tool>`) are banned on code Serena covers in this project " | |
| "(CLAUDE.md) — use Serena MCP tools (search_for_pattern, find_symbol, " | |
| "get_symbols_overview, read_file, list_dir, find_file) instead. " | |
| "Files/dirs git-ignores, or that .serena/project.yml doesn't index " | |
| "(node_modules, .venv, yaml/toml/lock/env/images, etc.), are fine " | |
| "with raw shell tools. If Serena genuinely cannot handle this target " | |
| "(not loadable, or a file type it does not parse), say so once to " | |
| "the user and ask them to relax .claude/settings.local.json rather " | |
| "than retrying this command." | |
| ) | |
| print( | |
| json.dumps( | |
| { | |
| "hookSpecificOutput": { | |
| "hookEventName": "PreToolUse", | |
| "permissionDecision": "deny", | |
| "permissionDecisionReason": reason, | |
| } | |
| } | |
| ) | |
| ) | |
| if __name__ == "__main__": | |
| try: | |
| main() | |
| except Exception: | |
| # Never block a command due to a bug in this hook itself. | |
| sys.exit(0) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Set this to your Claude settings.local.json
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "/path/to/your/deny-code-grep.sh", "timeout": 5 } ] } ] } }