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.
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.
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.
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)"]
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
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"]
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)
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 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
cargo install cc-toolgateRequires Rust 2024 edition (rustc 1.85+):
cargo install --path .Add to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/cc-toolgate",
"timeout": 5
}
]
}
]
}
}cc-toolgate ships with sensible defaults embedded in the binary. Override any part by creating ~/.config/cc-toolgate/config.toml.
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
# Move source/. from allow to ask (they execute arbitrary code)
[commands]
remove_allow = ["source", "."]
ask = ["source", "."]
# Auto-allow git push/pull when using a separate AI gitconfig
[git]
allowed_with_config = ["push", "pull", "add", "commit"]
config_env_var = "GIT_CONFIG_GLOBAL"
# Remove cargo run from safe subcommands (it executes arbitrary code)
[cargo]
remove_safe_subcommands = ["run"]cc-toolgate --dump-config # TOML output
cc-toolgate --dump-config json # JSON outputPass --escalate-deny to turn all DENY decisions into ASK. Useful when you trust the operator but want visibility:
{
"command": "/path/to/cc-toolgate --escalate-deny",
"timeout": 5
}Flat name-to-decision mapping. See config.default.toml for the full default lists.
git, cargo, kubectl, and gh have subcommand-aware evaluation with read-only vs. mutating distinctions, flag analysis, and optional env-gated auto-allow.
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
Requires cargo-nextest (process-per-test isolation needed for env-gate tests):
cargo nextest run # All tests
cargo nextest run -E 'test(heredoc)' # Filter by name pattern
cargo nextest run --test integration # Integration tests only- 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.
Most integration tests use the decision_test! macro for one-line declarations:
decision_test!(test_name, "command string", ExpectedDecision);
// e.g.
decision_test!(allow_git_log, "git log --oneline", Allow);
decision_test!(ask_rm, "rm -rf /tmp", Ask);
decision_test!(deny_shred, "shred /dev/sda", Deny);For tests that need reason assertions, custom registries, or multi-line heredoc commands, write a full #[test] fn block.
- 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.
- Rust 2024 edition (rustc 1.85+)
- A C compiler (for tree-sitter-bash; cc crate handles this automatically)
Decisions are logged to ~/.local/share/cc-toolgate/decisions.log (one line per evaluation).
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.