Skip to content

Instantly share code, notes, and snippets.

@krisanalfa
Created August 18, 2026 07:28
Show Gist options
  • Select an option

  • Save krisanalfa/c205ba5ac226965696f8722b4f4f3e8e to your computer and use it in GitHub Desktop.

Select an option

Save krisanalfa/c205ba5ac226965696f8722b4f4f3e8e to your computer and use it in GitHub Desktop.
Force Claude Code to Use Serena
#!/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)
@krisanalfa

Copy link
Copy Markdown
Author

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
          }
        ]
      }
    ]
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment