LSP-first guards: refuses shell greps on code symbols and shell writes to source files, retries cclsp's cold start, folds the refusals to one line

Keeps Claude on the LSP for code navigation. It refuses a shell grep on a code symbol (pointing to the LSP call instead) and a shell write to a source file (pointing to Edit or Write), quietly retries the LSP server when it is not warmed up yet, and shows each refusal as a single dim line. It replaces the old classic hooks in ~/.claude/hooks/ (unwired; saved in claude/hooks-lsp-first.disabled.json).
| Command | Verdict |
|---|---|
grep -rn "buildAgentTree" src/ | refused |
grep -n "flexShrink" types/index.d.ts | allowed (typings) |
cat > src/app.ts <<'TS' | refused (shell write to src/app.ts) |
echo x > /tmp/scratch.ts | allowed (scratch path) |
The transcript shows a refusal as one dim line:
⛔ LSP-FIRST: grep on code symbol(s) buildAgentTree: ask the LSP instead, mcp__cclsp__find_workspace_symbols("buildAgentTree") then …hooks/register.tsx 87 lines1// LSP-first guards, as a mod. Four hooks:
2// 1. a Bash command that greps for a code symbol, or writes a source file, is refused (lib/grep.ts, lib/write.ts);
3// 2. Read calls are noted, so cclsp can be primed on a file the session already opened;
4// 3. a cclsp call that hit the server's cold start ("No Project") is primed and run again, transparently;
5// 4. every LSP-first refusal, this mod's and the classic hooks', is drawn as one dim line in the transcript.
6// Plugin hooks run above the classic PreToolUse hooks, so this mod judges a call first.
7// $ stays in this file (the validator does not follow it across an import); the judging is pure, in lib/.
8import type { EngineInterface, McpToolResult, Register } from "claude-code";
9
10import { compactDenial, grepDenial, writeDenial } from "./lib/denials";
11import { grepSymbols } from "./lib/grep";
12import { shellWriteTarget } from "./lib/write";
13
14const CCLSP_SERVER = "cclsp";
15const CCLSP_TOOL_PREFIX = `mcp__${CCLSP_SERVER}__`;
16// what cclsp answers until a file-scoped call has primed its project (ktnyt/cclsp#43)
17const COLD_START_ERROR = /No Project\.|ThrowNoProject|Server not initialized|Project not loaded|LSP server.*not ready/i;
18const SOURCE_FILE = /\.(?:ts|tsx|js|jsx|mjs|cjs|py)$/i;
19// the fields of a tool.call event that are no argument of the MCP tool itself
20const EVENT_ENVELOPE_FIELDS = new Set(["tool", "tool_use_id", "agentId", "requestMeta"]);
21
22// the last source file Read opened: cclsp is primed on it when the failed call names no file of its own
23let lastSourceFileRead = "";
24
25const textOf = (mcpResult: McpToolResult) =>
26 mcpResult.content.map((block) => ("text" in block && typeof block.text === "string" ? block.text : "")).join("\n");
27
28// Primes cclsp with a file-scoped call (get_diagnostics), then makes the failed call again.
29// Resolves the new result, or null when there is no file to prime with or the call still fails.
30async function primeAndRetry($: EngineInterface, toolName: string, toolArguments: Record<string, unknown>) {
31 const primingFile = typeof toolArguments.file_path === "string" ? toolArguments.file_path : lastSourceFileRead;
32 if (!primingFile) return null;
33 await $.mcp.call(CCLSP_SERVER, "get_diagnostics", { file_path: primingFile });
34 const retried = await $.mcp.call(CCLSP_SERVER, toolName, toolArguments);
35 return retried.isError || COLD_START_ERROR.test(textOf(retried)) ? null : retried;
36}
37
38export const register: Register = (on) => {
39 // 1. No .catch on purpose: a guard that throws lets the command through (the engine says so in a dim line),
40 // rather than refusing every shell command until the bug is fixed.
41 on("tool.call", { tool: "Bash" }, ($, event, next) => {
42 const symbols = grepSymbols(event.command);
43 if (symbols.length > 0) return { deny: grepDenial(symbols) };
44
45 const writtenSourceFile = shellWriteTarget(event.command);
46 if (writtenSourceFile) return { deny: writeDenial(writtenSourceFile) };
47
48 return next(event);
49 });
50
51 // 2.
52 on("tool.call", { tool: "Read" }, ($, event, next) => {
53 if (SOURCE_FILE.test(event.file_path)) lastSourceFileRead = event.file_path;
54
55 return next(event);
56 });
57
58 // 3.
59 on("tool.call", async ($, event, next) => {
60 const ran = await next(event);
61 const isCclspCall = event.tool.startsWith(CCLSP_TOOL_PREFIX);
62 if (!isCclspCall || ran.deny !== undefined || !COLD_START_ERROR.test(ran.text ?? "")) return ran;
63
64 const toolArguments = Object.fromEntries(
65 Object.entries(event).filter(([field]) => !EVENT_ENVELOPE_FIELDS.has(field)),
66 );
67 const toolName = event.tool.slice(CCLSP_TOOL_PREFIX.length);
68 const retried = await primeAndRetry($, toolName, toolArguments).catch(() => null);
69 // ponytail: answers with the retry's content blocks; check live how the transcript draws such an MCP result
70 return retried ? { result: retried.content } : ran;
71 });
72
73 // 4.
74 on("ui.render", { component: "ToolResult" }, ($, event, next) => {
75 const { isErrored, output } = event.props;
76 const denialLine = isErrored && typeof output === "string" ? compactDenial(output) : null;
77 if (!denialLine) return next(event);
78 const { Text } = $.ui.resolve(event);
79
80 return (
81 <Text dimColor wrap="truncate-end">
82 {denialLine}
83 </Text>
84 );
85 });
86};
87hooks/lib/denials.ts 28 lines1// What the model reads when a guard refuses, and the one line the transcript shows in its place.
2
3// Starts every refusal of this mod, as it started the classic hooks' ones.
4const DENY_MARKER = "LSP-FIRST:";
5
6export const grepDenial = (symbols: readonly string[]) =>
7 `${DENY_MARKER} grep on code symbol(s) ${symbols.join(", ")}: ask the LSP instead, ` +
8 `mcp__cclsp__find_workspace_symbols("${symbols[0]}") then find_references from its definition. ` +
9 "Allowed: typings (.d.ts), one known file, regex patterns, -c, -A/-B/-C.";
10
11export const writeDenial = (target: string) =>
12 `${DENY_MARKER} writing the source file ${target} from the shell: use Edit (Read it first) or Write, ` +
13 "which show a diff and fail loudly on a missing anchor. Scratch paths (scratchpad/, /tmp) are exempt.";
14
15// The classic hooks prefixed their refusals: `PreToolUse:Bash hook error: [node ~/.claude/hooks/x.js]: ⛔ ...`.
16const CLASSIC_HOOK_PREFIX = /^[^]*?(?=⛔|LSP-FIRST|TOOL-CHOICE)/;
17
18// An LSP-first refusal (this mod's, or a classic hook's: LSP-FIRST or TOOL-CHOICE) as its first line, for the
19// transcript; null for any other error, which keeps its own display.
20export function compactDenial(errorText: string): string | null {
21 if (!/LSP-FIRST|TOOL-CHOICE/.test(errorText)) return null;
22 const lines = errorText
23 .replace(CLASSIC_HOOK_PREFIX, "")
24 .split("\n")
25 .map((line) => line.replace(/^⛔\s*/, "").trim());
26 return `⛔ ${lines.find(Boolean) ?? ""}`;
27}
28hooks/lib/grep.ts 170 lines1// Shell greps that look up a code symbol. An LSP lookup answers those better (the exact definition, the real
2// references), so the guard refuses them and names the LSP call to make instead.
3// Ported from the classic hook bash-grep-block.js.
4import { cleanCommand } from "./command";
5
6const SEARCH_PROGRAM = /\b(grep|rg|ag|ack)\b/i;
7
8// ── exemptions ───────────────────────────────────────────────────────────────
9// A grep that is no symbol lookup, or one no LSP call can answer, runs untouched.
10
11type Exemption = { reason: string; applies: (command: string) => boolean };
12
13// Programs whose output a piped grep filters: that text is no code an LSP knows.
14const OUTPUT_PRODUCER =
15 /^(?:npm|npx|pnpm|yarn|node|deno|bun|git|docker|make|kubectl|tsc|eslint|jest|vitest|ps|env|printenv|ls|find|wc|sort|uniq|head|tail|curl|dig)\b/i;
16
17// `npm test | grep FailedTest`: the grep reads a program's output, not files.
18function filtersProgramOutput(command: string) {
19 const pipeIndex = command.indexOf("|");
20 const searchIndex = command.search(SEARCH_PROGRAM);
21 const grepComesAfterPipe = pipeIndex !== -1 && pipeIndex < searchIndex;
22 // with xargs or -exec the grep reads files again, whatever comes before the pipe
23 if (!grepComesAfterPipe || /\bxargs\b|-exec\b/.test(command)) return false;
24 const producer = command
25 .slice(0, pipeIndex)
26 .trim()
27 .replace(/^\S*=\S*\s+/, ""); // a leading `VAR=value` is no program
28 return OUTPUT_PRODUCER.test(producer);
29}
30
31const RECURSIVE_FLAG = /\s-\w*[rR]\w*\b|--recursive|--include/i;
32const SOURCE_FILE_ARGUMENT =
33 /(?:^|\s)['"]?[\w./@~-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|vue|svelte)['"]?(?=\s|$|\||;)/gi;
34
35// `grep -n "UserService" user.service.ts`: "where in this file" is cheaper by grep than by any LSP call.
36function searchesOneKnownFile(command: string) {
37 if (RECURSIVE_FLAG.test(command)) return false;
38 return (command.match(SOURCE_FILE_ARGUMENT) ?? []).length === 1;
39}
40
41// Paths that point at code: a search naming one is a search of code.
42const CODE_PATH = /\bsrc[\\/]|\bapp[\\/]|components[\\/]|lib[\\/]|hooks[\\/]|utils[\\/]|services[\\/]|actions[\\/]/i;
43const CODE_SEARCH_HINTS = [
44 CODE_PATH,
45 /\.tsx?\b|\.jsx?\b/i,
46 /-t\s+(ts|tsx|js|jsx|typescript|javascript)\b/i,
47 /--type[= ](ts|tsx|js|jsx|typescript)\b/i,
48 /\bfind\b.*\b(src|app|components|lib)\b/,
49 /\bxargs\b.*\b(grep|rg|ag|ack)\b/i,
50 /-exec\s+(grep|rg|ag|ack)\b/i,
51];
52const NON_CODE_FILE = /\.(sql|md|json|yaml|yml|txt|env|sh|css|scss|log|toml|xml)\b/i;
53
54// `grep -r "UserService" docs/notes.md`: only non-code files are named, and nothing points at code.
55function searchesOnlyNonCodeFiles(command: string) {
56 const pointsAtCode = CODE_SEARCH_HINTS.some((hint) => hint.test(command));
57 return NON_CODE_FILE.test(command) && !pointsAtCode;
58}
59
60const GREP_EXEMPTIONS: Exemption[] = [
61 {
62 reason: "git grep: a deliberate search of the git index",
63 applies: (command) => /\bgit\s+grep\b/i.test(command),
64 },
65 {
66 reason: "typings (.d.ts): no LSP index holds their members (csstype's flexShrink), grep is the only way in",
67 applies: (command) => /\.d\.ts\b/.test(command),
68 },
69 {
70 reason: "-v drops lines: nobody looks for a definition by excluding it",
71 applies: (command) => /\b(?:grep|rg|ag|ack)\s+(?:-\w*v\w*|--invert-match)\b/i.test(command),
72 },
73 {
74 reason: "a filter on another program's output, not a search of files",
75 applies: filtersProgramOutput,
76 },
77 {
78 reason: "a tree that holds no indexed code (node_modules, migrations, .claude, notes)",
79 applies: (command) =>
80 // the tree name starts an argument (after a space or a quote) or a path segment (after a slash)
81 /(?:^|[\s'"/\\])(?:supabase[/\\]migrations|\.task|\.claude|node_modules|knowledge-vault)(?:[/\\]|$)/i.test(command),
82 },
83 {
84 reason: "--include of non-code files only",
85 applies: (command) => /--include=?\S*\.(sql|md|json|yaml|yml|txt|env|sh|css|scss|log)\b/i.test(command),
86 },
87 {
88 reason: "one known file: grep finds the line cheaper than any LSP call",
89 applies: searchesOneKnownFile,
90 },
91 {
92 reason: "-c counts occurrences: LSP has no count",
93 applies: (command) => /\b(?:grep|rg|ag|ack)\s+(?:-\w*c\w*|--count)\b/i.test(command),
94 },
95 {
96 reason: "-A/-B/-C asks for the lines around: LSP answers positions only",
97 applies: (command) => /\s-[ABC]\s?\d|--(?:after|before)-context\b|--context\b/i.test(command),
98 },
99 {
100 reason: "only non-code files are searched",
101 applies: searchesOnlyNonCodeFiles,
102 },
103];
104
105// ── the pattern and its symbols ──────────────────────────────────────────────
106
107// The searched pattern: double-quoted, single-quoted, or a bare PascalCase word.
108function searchPattern(command: string) {
109 const unescaped = command.replace(/\\"/g, '"');
110 const doubleQuoted = unescaped.match(/\b(?:grep|rg|ag|ack)\s+(?:-\S+\s+)*"([^"]+)"/i);
111 const singleQuoted = unescaped.match(/\b(?:grep|rg|ag|ack)\s+(?:-\S+\s+)*'([^']+)'/i);
112 const bareWord = unescaped.match(/\b(?:grep|rg|ag|ack)\s+(?:(?:-\w+\s+(?:[a-z]+\s+)?)*?)([A-Z][a-zA-Z]\w+)/i);
113 return (doubleQuoted ?? singleQuoted ?? bareWord)?.[1];
114}
115
116// `build.*Tree`, `Optional?`: an unescaped wildcard or quantifier makes a regex, and LSP looks up exact names only.
117// An escaped dot (`UserService\.`) is still a name; `\\` pairs are dropped first so `\\.` reads as an escape.
118const isRegex = (pattern: string) => /(?<!\\)[.?*+[]/.test(pattern.replace(/\\\\/g, ""));
119
120// Words that look like identifiers but are none.
121const NOT_SYMBOLS = [
122 /^(TODO|FIXME|HACK|XXX|NOTE)/i,
123 /^console\b/,
124 /^import\b/,
125 /^export\b/,
126 /^http/i,
127 /^\d/,
128 /^[A-Z_]{3,}$/, // a CONSTANT or an env var
129 /^[a-z]{1,8}$/, // a short plain word
130 /^[a-z]+-[a-z]+/, // kebab-case: a file name or a CSS class
131];
132
133function isSymbol(word: string, searchesPython: boolean) {
134 if (word.length < 4 || /\s/.test(word)) return false;
135 if (NOT_SYMBOLS.some((notSymbol) => notSymbol.test(word))) return false;
136 const isCamelCase = /^[a-z][a-zA-Z0-9]{3,}$/.test(word) && /[A-Z]/.test(word);
137 const isPascalCase = /^[A-Z][a-zA-Z][a-zA-Z0-9]{2,}$/.test(word);
138 // snake_case is a real symbol in Python only; in TypeScript it is a table or a column, which no LSP indexes
139 const isSnakeCase = searchesPython && /^[a-z]+(_[a-z]+){2,}$/.test(word) && word.length >= 9;
140 return isCamelCase || isPascalCase || isSnakeCase;
141}
142
143// The identifiers among a pattern's alternatives: `UserService|createOrder` gives both.
144function symbolsOf(pattern: string, searchesPython: boolean) {
145 return pattern
146 .split(/\\?\||\./) // alternatives, and each side of `a.b`
147 .map((word) =>
148 word
149 .replace(/\\[a-zA-Z]/g, " ") // an escape goes whole: `Dao\b` must not read as the PascalCase `Daob`
150 .replace(/[*+?^${}()[\]\\]/g, "")
151 .trim(),
152 )
153 .filter((word) => isSymbol(word, searchesPython));
154}
155
156// ── the verdict ──────────────────────────────────────────────────────────────
157
158// The code symbols a shell grep looks up, which the guard refuses; none when the command is no such lookup.
159export function grepSymbols(rawCommand: string): string[] {
160 const command = cleanCommand(rawCommand);
161 if (!SEARCH_PROGRAM.test(command)) return [];
162 if (GREP_EXEMPTIONS.some((exemption) => exemption.applies(command))) return [];
163
164 const pattern = searchPattern(command);
165 if (!pattern || isRegex(pattern)) return [];
166
167 const searchesPython = /\.py\b|--include=?\S*\.py\b|-t\s*py\b/i.test(command);
168 return symbolsOf(pattern, searchesPython);
169}
170hooks/lib/write.ts 101 lines1// Shell commands that write a source file: a redirect, tee, sed -i, or a script's own write call. Edit and Write
2// show the change as a diff and fail loudly on a missing anchor; a heredoc or a script's write_text() does neither.
3// Ported from the write half of the classic hook bash-file-read-block.js. Its read half (`cat`/`sed -n` on a source
4// file) is dropped on purpose: auto mode asks for those commands.
5import { EXTENSION_END, SOURCE_EXTENSION, cleanCommand } from "./command";
6
7// Where a write is no edit of the project: scratch space, VCS internals, build output.
8const SCRATCH_OR_TOOLING_PATH =
9 /(?:^|[/\\])(?:\.claude|\.git|\.task|coverage|\.next|\.turbo|knowledge-vault|node_modules[/\\]\.bin)(?:[/\\]|$)|(?:^|[/\\])(?:scratchpad|tmp|temp)[/\\]|^\/tmp[/\\]/i;
10
11const SOURCE_PATH = new RegExp(`${SOURCE_EXTENSION}${EXTENSION_END}`, "i");
12const isProjectSourceFile = (path: string) => SOURCE_PATH.test(path) && !SCRATCH_OR_TOOLING_PATH.test(path);
13
14// ── splitting the command ────────────────────────────────────────────────────
15
16// A heredoc's body is payload, not command: the TypeScript inside `cat > a.ts <<'TS'` would otherwise read as
17// redirects (`=>`) and source paths (`import "./x.ts"`). The body goes, its header line stays.
18function withoutHeredocBodies(command: string) {
19 const heredocStart = /<<-?\s*(['"]?)([A-Za-z_]\w*)\1/g;
20 let stripped = command;
21 for (const match of command.matchAll(heredocStart)) {
22 const delimiter = match[2];
23 const bodyStart = command.indexOf("\n", match.index);
24 if (bodyStart === -1) continue;
25 const bodyEnd = command.search(new RegExp(`\\n\\s*${delimiter}\\s*(?:\\n|$)`));
26 const body = bodyEnd === -1 ? command.slice(bodyStart) : command.slice(bodyStart, bodyEnd);
27 if (body) stripped = stripped.split(body).join("\n");
28 }
29 return stripped;
30}
31
32// The pipeline and list parts (`a | b; c && d`), split outside quotes only, each without its leading
33// `VAR=value` assignments and sudo/command wrappers, so each is judged by the program it runs.
34function commandParts(command: string) {
35 const parts: string[] = [];
36 let currentPart = "";
37 let openQuote: string | null = null;
38 for (let index = 0; index < command.length; index++) {
39 const character = command[index]!;
40 const isEscaped = command[index - 1] === "\\";
41 if (openQuote) {
42 if (character === openQuote && !isEscaped) openQuote = null;
43 currentPart += character;
44 } else if (character === '"' || character === "'") {
45 openQuote = character;
46 currentPart += character;
47 } else if ("|;\n&".includes(character)) {
48 parts.push(currentPart);
49 currentPart = "";
50 } else {
51 currentPart += character;
52 }
53 }
54 parts.push(currentPart);
55 return parts
56 .map((part) => part.trim().replace(/^(?:\w+=\S*\s+)*(?:sudo\s+|command\s+)?/, "").trim())
57 .filter(Boolean);
58}
59
60// ── the writes ───────────────────────────────────────────────────────────────
61
62// `> src/app.ts`, `>> src/app.ts`
63const REDIRECT_TARGET = new RegExp(String.raw`>>?\s*['"]?([\w./@~-]+${SOURCE_EXTENSION})${EXTENSION_END}`, "i");
64// `| tee src/app.ts`
65const TEE_TARGET = new RegExp(String.raw`\btee\b[^|]*?['"]?([\w./@~-]+${SOURCE_EXTENSION})${EXTENSION_END}`, "i");
66// any source path among a command's arguments, for `sed -i`
67const SOURCE_ARGUMENT = new RegExp(String.raw`[\w./@~-]+${SOURCE_EXTENSION}${EXTENSION_END}`, "i");
68
69// The source file a command part writes through the shell itself: a redirect, tee, or an in-place sed.
70function shellWrittenFile(part: string) {
71 const redirected = (part.match(REDIRECT_TARGET) ?? part.match(TEE_TARGET))?.[1];
72 if (redirected) return redirected;
73 const isInPlaceSed = /^sed\b/i.test(part) && /\s-i\b/.test(part);
74 return isInPlaceSed ? part.match(SOURCE_ARGUMENT)?.[0] : undefined;
75}
76
77// A script's write call, by the path literal it names: Python's `Path("a.ts").write_text(...)` and
78// `open("a.ts", "w")`, Node's `writeFileSync("a.ts", ...)`. Only that path counts, so a script that writes
79// a .json while merely mentioning a .ts is left alone.
80// ponytail: a path held in a variable (`open(target, "w")`) goes unseen; resolve the assignment if that ever matters
81const SCRIPT_WRITE_CALLS = [
82 /\bPath\(\s*['"]([^'"]+)['"]\s*\)\s*\.write_text\s*\(/g,
83 /\bopen\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"][wa]\+?['"]/g,
84 /\bwriteFileSync\s*\(\s*['"]([^'"]+)['"]/g,
85];
86
87function scriptWrittenFiles(command: string) {
88 return SCRIPT_WRITE_CALLS.flatMap((writeCall) => [...command.matchAll(writeCall)].map((match) => match[1]!));
89}
90
91// ── the verdict ──────────────────────────────────────────────────────────────
92
93// The project source file a shell command writes, which the guard refuses; null when it writes none.
94export function shellWriteTarget(rawCommand: string): string | null {
95 const command = cleanCommand(rawCommand);
96 const shellWrites = commandParts(withoutHeredocBodies(command)).map(shellWrittenFile);
97 // the script's code sits in the heredoc body: its write calls are looked for in the whole command
98 const candidates = [...shellWrites, ...scriptWrittenFiles(command)];
99 return candidates.find((path): path is string => !!path && isProjectSourceFile(path)) ?? null;
100}
101hooks/lib/command.ts 14 lines1// What the grep and write guards share about a shell command.
2
3// Zero-width and formatting characters could split a word invisibly (`grepUserService`) and slip past
4// every check below: they are removed before anything is matched.
5const ZERO_WIDTH_CHARACTERS = /[--]/g;
6
7export const cleanCommand = (rawCommand: string) => rawCommand.trim().replace(ZERO_WIDTH_CHARACTERS, "");
8
9// The extensions of the source files an LSP indexes, as a regex fragment.
10export const SOURCE_EXTENSION = String.raw`\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|vue|svelte|cpp|c|h|hpp)`;
11
12// Closes SOURCE_EXTENSION: without it the `.c` extension would match the start of `data.csv`.
13export const EXTENSION_END = String.raw`(?![A-Za-z0-9_])`;
14