cc-toolgate
A PreToolUse hook for Claude Code that gates Bash commands before execution. Every command Claude wants to run is parsed, classified, and either allowed silently, prompted for confirmation, or denied outright.
Why
Claude Code can run arbitrary shell commands. That's powerful but dangerous — a misguided rm -rf, an unauthorized kubectl apply, or a sudo invocation shouldn't happen without your say-so. cc-toolgate sits between Claude and the shell, evaluating every command against configurable rules.
Decision model
Every command gets one of three decisions:
| Decision | Behavior | When |
|---|---|---|
| ALLOW | Runs silently | Read-only commands, safe tools (ls, git status, cargo build) |
| ASK | Claude Code prompts you | Mutating commands (rm, git push), unrecognized commands |
| DENY | Blocked outright | Destructive commands (shred, dd, mkfs) |
Redirection on allowed commands (e.g. echo foo > file.txt) automatically escalates to ASK.
Architecture
cc-toolgate uses a tree-sitter-bash parser to build a full AST of the command, then walks it to extract segments, operators, substitutions, and redirections for evaluation.
graph LR
stdin["stdin (JSON)"] --> main
main --> lib["lib.rs<br/>evaluate()"]
lib --> registry["eval/<br/>CommandRegistry"]
registry --> shell["parse/shell.rs<br/>tree-sitter AST"]
registry --> tokenize["parse/tokenize.rs<br/>shlex splitting"]
registry --> commands["commands/*<br/>CommandSpec"]
registry --> result["Decision<br/>+ reason"]
result --> stdout["stdout (JSON)"]
Module layout
src/
main.rs Entry point, CLI flags (84 lines)
lib.rs Re-exports, top-level evaluate() orchestrator
config.rs TOML config loading, ConfigOverlay merge system
parse/
mod.rs Re-exports
shell.rs tree-sitter-bash AST walker: compound splitting,
substitution extraction, redirection detection
tokenize.rs shlex-based word splitting, base_command(), env_vars()
types.rs ParsedPipeline, ShellSegment, Operator, Redirection
eval/
mod.rs CommandRegistry, strictest-wins aggregation
context.rs CommandContext struct
decision.rs Decision enum, RuleMatch
commands/ CommandSpec implementations per tool category
simple.rs Flat allow/ask/deny lists
deny.rs Always-deny commands (shred, dd, mkfs, etc.)
git.rs Subcommand-aware git evaluation
cargo.rs Subcommand-aware cargo evaluation
kubectl.rs Subcommand-aware kubectl evaluation
gh.rs Subcommand-aware gh CLI evaluation
logging.rs File appender for decision log
tests/
integration.rs integration tests (decision_test! macro)
config.default.toml Embedded default config
How it works
flowchart TD
A["Claude Code calls Bash tool"] --> B["PreToolUse hook pipes JSON to cc-toolgate"]
B --> C["Parse command with tree-sitter-bash"]
C --> D["Walk AST"]
D --> E["Extract segments + operators"]
E --> F{"For each segment"}
F --> G["Resolve wrapper commands<br/>(sudo, xargs, env, ...)"]
G --> H["Evaluate against CommandRegistry"]
H --> I["Check redirections"]
I --> J["Check command substitutions<br/>recursively"]
J --> F
F --> K["Strictest decision wins"]
K --> L["Output JSON to stdout"]
L --> M{"Decision?"}
M -->|ALLOW| N["Runs silently"]
M -->|ASK| O["Claude Code prompts you"]
M -->|DENY| P["Blocked outright"]
Command parsing
cc-toolgate uses tree-sitter-bash to parse commands into a full AST before evaluation. This replaced an earlier hand-rolled parser and fixes a class of bugs around heredocs, nested quoting, and operator extraction.
The AST walker handles these node types:
graph TD
program --> list & pipeline & command & redirected_statement
list --> |"&&, ||, ;"| pipeline
pipeline --> |pipe| command
command --> simple_command & subshell & compound
compound --> for_statement & while_statement & if_statement & case_statement
redirected_statement --> command & heredoc_redirect
heredoc_redirect --> |"pipe/&&/||/;"| next_command
Key behaviors:
- Heredoc pipes:
cat <<'EOF' | kubectl apply -f -correctly identifies both thecatandkubectl applysegments - Command substitutions:
$(...)and backticks are recursively evaluated; single-quoted strings are not expanded - Process substitutions:
<(...)and>(...)are recognized without false redirection detection - /dev/null: Redirections to
/dev/nulldon't escalate (they're non-mutating) - fd duplication:
2>&1,>&2are safe;>&3escalates (could write to a file)
Wrapper commands
Commands that execute their arguments (like sudo, xargs, env) are evaluated recursively. The wrapped command is extracted and evaluated, and the final decision is the stricter of the wrapper's floor and the inner command's decision.
sudo rm -rf / → max(ask_floor, ask) = ASK
sudo shred /dev/sda → max(ask_floor, deny) = DENY
xargs grep foo → max(allow_floor, allow) = ALLOW
env FOO=bar rm file → max(allow_floor, ask) = ASK
Compound commands
Compound expressions are split and each part evaluated independently:
git status && rm -rf /tmp/stuff → max(allow, ask) = ASK
echo hello | kubectl apply -f - → max(allow, ask) = ASK
Installation
From crates.io
From source
Requires Rust 2024 edition (rustc 1.85+):
Hook configuration
Add to ~/.claude/settings.json:
Configuration
cc-toolgate ships with sensible defaults embedded in the binary. Override any part by creating ~/.config/cc-toolgate/config.toml.
Merge behavior
User config merges with defaults — you only specify what you want to change:
- Lists extend defaults (deduplicated)
- Scalars override defaults
remove_<field>subtracts items from default listsreplace = truein any section replaces defaults entirely for that section
Example user config
# Move source/. from allow to ask (they execute arbitrary code)
[]
= ["source", "."]
= ["source", "."]
# Auto-allow git push/pull when using a separate AI gitconfig
[]
= ["push", "pull", "add", "commit"]
= "GIT_CONFIG_GLOBAL"
# Remove cargo run from safe subcommands (it executes arbitrary code)
[]
= ["run"]
Inspecting effective config
Escalate deny
Pass --escalate-deny to turn all DENY decisions into ASK. Useful when you trust the operator but want visibility:
Command categories
Simple commands (allow / ask / deny)
Flat name-to-decision mapping. See config.default.toml for the full default lists.
Complex command specs
git, cargo, kubectl, and gh have subcommand-aware evaluation with read-only vs. mutating distinctions, flag analysis, and optional env-gated auto-allow.
Wrapper commands
Commands in the [wrappers] section execute their arguments as subcommands. Each has a floor decision:
allow_floor:xargs,parallel,env,nohup,nice,timeout,time,watch,strace,ltraceask_floor:sudo,su,doas,pkexec
Testing
Running tests
cargo-nextest is recommended for faster parallel execution and better output:
Test structure
- Unit tests: Colocated in
src/modules with#[cfg(test)]. These test internal parsing and evaluation logic and needsuper::*access to private helpers. - Integration tests: In
tests/integration.rs. These test end-to-end command evaluation through the public API.
Adding tests
Most integration tests use the decision_test! macro for one-line declarations:
decision_test!;
// e.g.
decision_test!;
decision_test!;
decision_test!;
For tests that need reason assertions, custom registries, or multi-line heredoc commands, write a full #[test] fn block.
Contributing
Project structure at a glance
- Adding a new command rule: Edit
config.default.tomlto add it to the appropriate list, or add a newCommandSpecundersrc/commands/. - Adding a new complex command: Create a new file in
src/commands/, implementCommandSpec, and register it insrc/eval/mod.rs. - Parser changes: Modify
src/parse/shell.rs(tree-sitter AST walker) orsrc/parse/tokenize.rs(shlex tokenizer). - Adding a test: Add a
decision_test!()line totests/integration.rsunder the appropriate section.
Build requirements
- Rust 2024 edition (rustc 1.85+)
- A C compiler (for tree-sitter-bash; cc crate handles this automatically)
Logging
Decisions are logged to ~/.local/share/cc-toolgate/decisions.log (one line per evaluation).
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT License (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.