| name | no-bash |
| description | Use fff MCP / Grep / Read / Glob — never Bash grep/cat/find/head/tail/sed/awk/rg/wc — for file search and read. Use jq for JSON parsing/shaping (not node -e / python -c). A hook hard-blocks the banned Bash calls, and the dedicated tools return clean, cacheable, line-numbered results. Auto-invoke when searching, reading, or counting in files. |
| user-invocable | true |
Use Dedicated Tools, Not Bash
Self-Check
Before every Bash call, ask: "Am I reading or searching file contents? Am I running a git command?" If reading/searching files — use fff first, the built-in tool second. If running git commands — route to gh / ! git (exception: git mv stays in Bash). Period.
Why
The reason is not permission clicks: Claude Code runs grep/cat/head/tail/find/wc as native read-only commands with no prompt. Prefer the dedicated tools for three concrete reasons:
- Grep/Read return clean, paginated, line-numbered results instead of raw terminal dumps the model then re-parses.
- Built-in tool approvals cache. Unique Bash content, especially
cat > file heredocs, never caches, so a human re-reviews it on every run (the heaviest pattern in claude-code#19649).
- The hook fires where the system prompt fails. The model ignores its own "don't use Bash for this" instruction ~40% of sessions, more after context compaction and in subagents. Anthropic closed that as not-planned (claude-code#39979), so enforcement has to live in a hook.
The hooks plugin (hooks@ronin-skills, optional install) ships the hook (plugins/hooks/hooks/no-bash.mjs) that hard-blocks the wrong Bash calls. This skill is the educational mapping and lives in agent; the hook is the enforcement and lives in hooks. Without the hooks plugin installed, this skill still guides; it just isn't enforced. Full rationale and sources: plugins/hooks/hooks/no-bash.md.
Tool Preference Order
For file search / read
- fff MCP — first choice for any file search or content grep inside a git-indexed directory. Frecency-ranked results (frequent/recent files first, dirty files boosted), git-aware, constraint-aware. See README.md → "fff instead of grep/bash etc.".
- Built-in
Grep / Read / Glob — fallback when fff isn't installed or the search target lies outside the git tree. Read is present on every build. The Grep and Glob tools were removed on native macOS/Linux builds in Claude Code 2.1.117 (replaced by Bash ugrep/bfs, which return raw dumps this hook routes away from); on those builds fff is the structured search path. Windows and npm-installed builds keep Grep/Glob.
- Bash — only for the legitimate uses listed at the bottom of this skill.
For JSON parsing / shaping
jq — first choice for any JSON work: reshaping API output, extracting fields, building hook input fixtures, constructing test payloads. Shorter than node -e / python -c, no quoting hell, no subprocess-bypass risk. (Add Bash(jq:*) to permissions.allow to skip the prompt.)
node -e / python -c — fallback only when the logic needs language features jq lacks (complex control flow, regex flavors, library calls). NEVER for shelling out — hook blocks subprocess APIs.
The Rule
Never use Bash for reading or searching files. Both fff and the built-in tools cover every case including multiline, context lines, counting, and pagination.
Mapping Table
| Instead of this Bash | First choice (fff MCP) | Built-in fallback |
|---|
grep "pattern" file | mcp__fff__grep query: "file pattern" | Grep pattern, path, output_mode: "content" |
grep -r "pattern" dir | mcp__fff__grep query: "dir/ pattern" | Grep pattern, path: "dir" |
grep -rl "pattern" | mcp__fff__grep output_mode: "files_with_matches" | Grep output_mode: "files_with_matches" |
grep -c "pattern" | mcp__fff__grep output_mode: "count" | Grep output_mode: "count" |
grep --include="*.ts" | mcp__fff__grep query: "*.ts pattern" | Grep glob: "*.ts" |
rg -U "multiline" | n/a (use built-in) | Grep multiline: true |
| Multiple identifiers (OR) | mcp__fff__multi_grep patterns: [...] | sequential Grep calls |
find . -name "*.ts" | mcp__fff__find_files query: "name **/*.ts" | Glob pattern: "**/*.ts" |
find . -name "*test*" | mcp__fff__find_files query: "test" | Glob pattern: "**/*test*" |
cat file / cat -n file | n/a (use built-in) | Read file_path |
cat > file <<EOF (creation) | n/a (use built-in) | Write file_path, content (NOT Read, this writes) |
head -100 file | n/a (use built-in) | Read limit: 100 |
tail -n +50 file | head -30 | n/a (use built-in) | Read offset: 50, limit: 30 |
sed -n '50,80p' file | n/a (use built-in) | Read offset: 50, limit: 31 |
awk '/pat/ {print}' file | mcp__fff__grep query: "file pat" | Grep pattern: "pat", path: "file" (or Read) |
ls dir/ (listing) | mcp__fff__find_files query: "dir/" | Glob pattern: "dir/*" |
wc -l file | n/a (use built-in) | Grep pattern: ".", output_mode: "count", path: "file" |
fff Core Rules
- Search bare identifiers, not code syntax or regex.
ActorAuth (good); struct ActorAuth (bad — adding keywords narrows results and misses traits/enums).
- Use constraints to prefilter:
*.rs query, src/ query, name **/src/*.{ts,tsx} !test/.
- Stop after 2 greps — READ. After 2 grep calls you have enough file paths. Read the top result. More greps ≠ better understanding.
multi_grep for OR logic across naming conventions (ActorAuth, actor_auth, populatedActorAuth) — one call, not three.
See fff's own server instructions for the full constraint syntax.
Multiline — Built-in Only
mcp__fff__grep matches within single lines. For cross-line patterns use the built-in Grep tool with multiline: true (equivalent to rg -U --multiline-dotall).
Grep(pattern: "struct \\{[\\s\\S]*?field", multiline: true, path: "src/")
There is NO reason to use Bash(rg -U ...).
Compound Built-in Calls
grep -rl --include="*.ts" -A 5 -i "pattern" src/ becomes one Grep call:
Grep(pattern: "pattern", path: "src/", glob: "*.ts", -A: 5, -i: true, output_mode: "content")
No command chaining (&& / \|\| / ;). Pipes only for allowed cases (e.g. into jq).
No Command Chaining
Run one command per Bash call. The hook blocks any command that joins two commands with &&, ||, or ;, because chaining hides the later commands from per-command approval (a documented permission-bypass vector) and is the bash-one-liner reflex, not a need.
pnpm build && pnpm test is two calls: run pnpm build, then run pnpm test.
cd dir && cmd is unnecessary: the Bash working directory persists across calls, so cd dir once (or just use a relative path) and run cmd separately.
- Never append a status probe like
; echo "exit=$?" or && echo done. The tool result already shows exit status and output.
Allowed because the data must flow in one process, or there is no second command:
- a pipe into
jq for JSON shaping (gh pr view --json title | jq .title)
- redirections (
> file, >> file, 2>&1)
What Stays in Bash
These are legitimate Bash uses — either they have no dedicated tool equivalent, or they're narrow, read-only commands (like a simple ls) where Bash is fine. The blanket rule still applies for file search / read / mutation: use dedicated tools.
- gh:
gh pr view, gh pr list, gh run view, gh api, etc. Prefer gh over git for reading remote state. (Add Bash(gh:*) to your permissions.allow to skip the prompt — the plugin can't ship that rule for you.)
- pnpm/npm:
pnpm install, pnpm build, pnpm test, etc.
- docker:
docker exec, docker ps, docker compose, etc.
- build/dev tools:
mvn, npx, pnpx, etc.
- Process management:
lsof, kill, pkill
- File mutations:
mkdir, cp, git mv
- Environment:
which, java -version
jq — first-class JSON tool (see "For JSON parsing / shaping" above). Use it. (Add Bash(jq:*) to permissions.allow to skip the prompt.)
node -e / python -c — allowed only for in-process logic (math, control flow). NOT for shelling out to banned tools via Node's subprocess APIs or Python's subprocess module. The hook hard-blocks the shell-out case. For JSON, prefer jq.
- Simple
ls — Bash ls is permitted for narrow, read-only directory listing, but fff MCP (mcp__fff__find_files) is preferred for searching/reading files.
When the hook blocks a command
A BLOCKED line from this hook is a deterministic environment rejection — NOT the user rejecting you. The block message says so explicitly. Do not narrate it as "the user rejected my command"; the user did not act. Read the reason= field, switch to the dedicated tool, and never re-issue the same blocked command unchanged — it will fail identically.
Switch to the dedicated tool (Grep / Read / Glob / fff / jq) — that is the fix. If the message names Grep or Glob and that tool is not in this session, it was removed on native macOS/Linux builds in 2.1.117; use fff (mcp__fff__grep / mcp__fff__find_files) or Read instead, and still do not fall back to Bash. Don't route around the block with command, an absolute path, \grep, xargs, bash -c, or a node/python shell-out — the hook catches those too. It also evaluates each segment of a compound command separately, so hiding a banned tool after ; && || | &, inside a subshell (grep …), a brace group { grep …; }, a process substitution <(grep …), or a command substitution $(grep …) is blocked all the same. And it blocks gratuitous chaining (&&/||/; joining two commands) — run each as a separate Bash call. A bypass is a bug to fix in plugins/hooks/hooks/no-bash.mjs, not a loophole to exploit.
git Commands (permission routing)
Mutating git commands aren't auto-allowed and prompt every time (the hook blocks them too), so route around the prompt: gh for remote state (PRs, branches, CI); the ! prefix for local reads (! git log, ! git fetch) so the output lands in the conversation; git mv is a file mutation, so plain Bash is fine. (For git safety — what not to run — see agent:git.)