# cc-toolgate
A [PreToolUse hook](https://docs.anthropic.com/en/docs/claude-code/hooks) 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:
| **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.
```mermaid
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
```mermaid
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](https://github.com/tree-sitter/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:
```mermaid
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 the `cat` and `kubectl apply` segments
- **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/null` don't escalate (they're non-mutating)
- **fd duplication**: `2>&1`, `>&2` are safe; `>&3` escalates (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
## Installation
### From crates.io
```bash
cargo install cc-toolgate
```
### From source
Requires Rust 2024 edition (rustc 1.85+):
```bash
cargo install --path .
```
### Hook configuration
Add to `~/.claude/settings.json`:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/cc-toolgate",
"timeout": 5
}
]
}
]
}
}
```
## 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 lists
- **`replace = true`** in any section replaces defaults entirely for that section
### Example user config
```toml
# 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"]
```
### Inspecting effective config
```bash
cc-toolgate --dump-config # TOML output
cc-toolgate --dump-config json # JSON output
```
### Escalate deny
Pass `--escalate-deny` to turn all DENY decisions into ASK. Useful when you trust the operator but want visibility:
```json
{
"command": "/path/to/cc-toolgate --escalate-deny",
"timeout": 5
}
```
## 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`, `ltrace`
- **`ask_floor`**: `sudo`, `su`, `doas`, `pkexec`
## Testing
### Running tests
```bash
cargo test # Run all tests
cargo test --lib # Unit tests only
cargo test --test integration # Integration tests only
```
[cargo-nextest](https://nexte.st/) is recommended for faster parallel execution and better output:
```bash
cargo nextest run # All tests
cargo nextest run -E 'test(heredoc)' # Filter by name pattern
```
### Test structure
- **Unit tests**: Colocated in `src/` modules with `#[cfg(test)]`. These test internal parsing and evaluation logic and need `super::*` 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:
```rust
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.
## Contributing
### Project structure at a glance
1. **Adding a new command rule**: Edit `config.default.toml` to add it to the appropriate list, or add a new `CommandSpec` under `src/commands/`.
2. **Adding a new complex command**: Create a new file in `src/commands/`, implement `CommandSpec`, and register it in `src/eval/mod.rs`.
3. **Parser changes**: Modify `src/parse/shell.rs` (tree-sitter AST walker) or `src/parse/tokenize.rs` (shlex tokenizer).
4. **Adding a test**: Add a `decision_test!()` line to `tests/integration.rs` under 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](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
- MIT License ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.