A week ago I installed the TypeScript LSP plugin for Claude Code. The pitch sold me. The agent works the way I do in an editor, navigating with go-to-definition and find-references instead of grepping around, and catching type errors as it writes instead of at the end of a task. I watched it work in that session and forgot about it.
A week later I went back to check what it had bought me. Nine calls, all inside the session where I tested it, on August 25. Zero across the twenty-seven sessions since.
That was annoying enough to go digging.
Everything moved to Bash
I pulled every session transcript from ~/.claude/projects for one repo, a large Next.js app I work in daily. 76 sessions, 8,343 tool calls, all on Opus 5, July 31 to September 1.
Across the whole period Bash took 70% of all tool calls. Over the last two weeks, 84%, while the built-in file tools fell to 7%.
(defaultMode: "auto" the whole window, so that didn't change.)
grep 1,281, Grep 0
Grep and Glob were called zero times in 8,343 tool calls. grep ran 1,165 times through Bash and rg another 116.
Here's one from my transcripts, reading a file:
sed -n 269,352p src/domains/billing/server/database/read.ts
This one ran three times in a row across different files:
perl -pi -e 's{\bvendorContracts\b}{importedContracts}g' previewVendorContractsSyncFunction.ts
perl -pi to rename a symbol. In a repo where the LSP plugin with
find-references was installed and enabled. I stared at that one for a while, wondering if some phrasing of mine had nudged it toward the shell. No idea. There were 325 python3 and perl rewrites doing what Edit does natively.
When Claude actually modified a file it mostly used Edit and Write. The swaps happen when it reads and searches.
Where my permission rules went
The permissions docs explain a big chunk of it. A fixed set of Bash commands runs with no permission prompt in every mode:
These include
ls,cat,head,grep,find, and read-only forms of
git. The set is not configurable.
Every shell command it reached for instead of a built-in is on that list. 22%of my 5,837 Bash calls used nothing but commands from that list, 31% counting git. None of them ever prompted.
Two of the rules in my deny list:
"deny": ["Read(**/.env)", "Bash(rm -rf:*)"]
Here is a command from my own transcripts, August 11:
DB=$(grep -m1 '^DATABASE_URL' .env | sed 's/^DATABASE_URL=//; s/^"//; s/"$//') \
&& psql "$DB" -c 'select u.email, um."organizationId", o.name, um.role from "User" u ...'
Read(**/.env) is denied, but that call is not a Read. It is a grep, on the prompt-free list, so it never asked.
What the changelog says
The v2.1.116 changelog says Glob and Grep are deliberately removed on native macOS and Linux builds, "replaced by embedded bfs and ugrep available through the Bash tool — faster searches without a separate tool round-trip." On my machine, which bfs ugrep finds neither. The tools reference still lists Grep and Glob. The native build ships neither them nor the replacements.
I don't know whether that is deliberate or a build regression nobody caught.
There are open issues either way.#64136 ties the missing tools to Bash storms and fabricated file paths.
#52121 describes my exact setup, and calls it "particularly problematic for systems where bash grep/find are restricted by permission policies, as there's no alternative code-search path." And #80267, labelled
reproduced by maintainers, treats shell-based file editing as normal: diagnostics go stale "because Claude Code likes to use sed, python, and similar tools to edit files in place".
What I tried
An undocumented env var helped a little (CLAUDE_CODE_THRIFTY_SONIC=false, mentioned in one of those issues, documented nowhere). Read and Edit came back, grep still won. So I wrote a PreToolUse hook that denies Bash for
anything a built-in tool does better.
Blocking grep did not make Claude reach for Grep. It reached for git grep, then git ls-files, and I had to close those specifically. With those closed, it finally uses the right tools. It costs a few calls at the start of every session while the model works out what's blocked.
A hook that blocks grep and find leaves no search path at all. So the current setup allows rg, fd, ls, grep and find through and keeps denying the rest.
The hook and the scripts are at Dionysos5/claude-code-tool-mix.
The part I can't get past
Nothing in my settings changed. I only found out because a plugin got zero calls.
The deny list is still sitting in my settings and I couldn't tell you what else it doesn't cover. Permission rules bind to tool names, and the harness picks the tool. Am I supposed to write a rule for every shell equivalent of every built-in, and keep them current against a build that changes weekly? That's roughly what my hook is now, and I don't think it should be my job.


Top comments (1)
Your last paragraph is the one I keep hitting too: permission rules bind to tool names, and the harness picks the tool. But I found the deny/redirect distinction matters more than the coverage.
I run a PreToolUse hook on Bash that blocks one narrow thing: Cyrillic identifiers in shell. Not because of tooling preference — because
$имяsilently expands to an empty string, and the command runs anyway with a missing argument. Failure looks like success.The hook doesn't deny the tool. It denies one shape and names the replacement: "write a Python file instead of a bash variable." Month of use, zero circumvention.
Your grep case went the other way: you blocked it, and the model reached for
git grep, thengit ls-files. That reads like the difference between a closed door and a signpost. A denial with no named alternative leaves the model to search the space of equivalents, and that space is exactly as large as you fear — one rule per shell equivalent, forever.The other half of your finding cost me four hours today, in a different form. I had a counter reporting "windows: 1" while my user was looking at three. It counted WindowsTerminal processes; the terminal holds multiple windows in one process. Every fix I made was to working code, because the instrument answered a question I wasn't asking.
Same shape as your plugin: nothing failed, nothing errored, the number was true about the wrong noun. You found it because a plugin got zero calls. I found it because a human kept saying "you're in the wrong window" while my measurement said otherwise.
The uncomfortable version: my rule now is that when a person's eyes disagree with my instrument, the instrument is wrong until proven otherwise. Three of my four corrections today came from that, not from reading code.
Did CLAUDE_CODE_THRIFTY_SONIC=false hold up over multiple sessions, or did the ratio drift back? Curious whether it's a real switch or a warm-start artifact.