A pre-tool-use hook for Claude Code that provides comprehensive permission checking for bash commands, file operations, and other tools. Supports multiple tool types, extended pattern syntax, and detailed logging.
Toolguard is a drop-in replacement for the native Claude code permissions system for Bash, Read, Write, and Edit tools. It is backwards compatible (as of January 2026), and has extended capabilities, and better coverage and safety. It also addresses some of the bugs that have existed for a while in the native system, which have not been fixed as of this writing.
- Unified configuration: Single permission system for multiple tools (Bash, JetBrains terminal, etc.)
- Compound command security: Parse and validate each sub-command separately
- Extended pattern types: Support regex, glob with globstar, and Claude Code 2.10 native syntax
- Path normalization: Consistent, documented normalization of paths in commands and patterns
- Better logging: Comprehensive audit trail of all command decisions
Toolguard requires configuration in two places:
- Hook matchers in Claude Code settings - tells Claude which tools trigger the hook
- Governed tools list in toolguard config - tells toolguard which tools to actually check
Both must be configured for each tool you want to govern.
Add hook matchers for each tool in .claude/settings.local.json. For example:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
},
{
"matcher": "mcp__jetbrains__execute_terminal_command",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
},
{
"matcher": "mcp__local-tools__checked_bash",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
},
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
},
{
"matcher": "Write",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
},
{
"matcher": "Edit",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
}
]
}
}Important: Replace /path/to/toolguard/run_hook.sh with the absolute path to the run_hook.sh wrapper script in your toolguard checkout. This wrapper invokes python -m toolguard.hook using the project's virtual environment.
Create .claude/toolguard_hook.toml (preferred) or .claude/toolguard_hook.json with the list of tools to govern.
TOML format (recommended):
governed_tools = [
"Bash",
"mcp__jetbrains__execute_terminal_command",
"Read",
"Write",
"Edit"
]JSON format:
{
"governed_tools": [
"Bash",
"mcp__jetbrains__execute_terminal_command",
"Read",
"Write",
"Edit"
]
}Note: If both .toml and .json files exist, the TOML file takes precedence and a warning is logged.
Toolguard has a hardcoded list of known supported tools (Bash, Read, Write, Edit, and mcp__jetbrains__execute_terminal_command). If you have custom MCP tools that execute commands and want toolguard to govern them, add them to additional_supported_tools:
additional_supported_tools = [
"mcp__custom__my_bash_tool",
"mcp__other__command_runner"
]
governed_tools = [
"Bash",
"mcp__custom__my_bash_tool"
]This tells toolguard:
- These tools are valid and should not generate "unsupported tool" warnings
- If they're also in
governed_tools, their commands will be validated
Why is this needed? Toolguard validates your configuration at startup and warns about tools that appear in permissions but aren't recognized. Without additional_supported_tools, custom MCP tools would trigger warnings.
Command Tools (execute shell commands):
| Tool | Description | When to Include |
|---|---|---|
Bash |
Native Claude Code bash tool | Always - this is the primary tool |
mcp__jetbrains__execute_terminal_command |
JetBrains IDE terminal | If using JetBrains MCP integration |
Also, check your MCP tool list. Any tool that can execute bash commands should be included in the list.
File Path Tools (read/write/edit files):
| Tool | Description | When to Include |
|---|---|---|
Read |
Claude Code file read tool | If you want GLOB pattern control over file reading |
Write |
Claude Code file write tool | If you want GLOB pattern control over file writing |
Edit |
Claude Code file edit tool | If you want GLOB pattern control over file editing |
Why govern file path tools? Claude Code has a known bug where ** globstar patterns work correctly for Read permissions but NOT for Write or Edit. Toolguard uses Python's PurePath.full_match() which correctly implements globstar semantics for all file operations. There are also bugs where the native permissions system sometimes prompts youfor permissions that are already granted in the configuration. This can stop a flow dead in its tracks while you're away from the terminal. Toolguard reclaims your time.
Note on Subagents: Subagents use the same tools as the main agent. If a subagent is configured to use a specific tool (e.g., an MCP bash tool), that tool must be in both the hook matchers AND the governed_tools list, or commands will bypass toolguard entirely.
Permission patterns can be configured in two places:
settings.local.json- Standard patterns that Claude Code understands nativelytoolguard_hook.json- Extended syntax patterns ([regex],[glob],[native])
Why separate files? If you ever need to disable the toolguard hook, patterns in settings.local.json will still work with Claude Code's native permission system. Extended syntax patterns in toolguard_hook.json are toolguard-specific and won't pollute the native configuration.
Add permission patterns in the permissions section of your Claude settings:
Command tool patterns (for Bash and terminal tools):
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(uv run pytest:*)",
"Bash(ls -la:*)"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(sudo:*)"
]
}
}All command patterns use the Bash(...) prefix regardless of which tool is being governed. This provides a unified permission model across all command-executing tools.
File path patterns (for Read, Write, Edit tools):
{
"permissions": {
"allow": [
"Read(~/projects/**)",
"Read(/tmp/**)",
"Write(~/projects/myapp/**)",
"Write(/tmp/**)",
"Edit(~/projects/myapp/**)"
],
"deny": [
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/.ssh/**)",
"Write(**/.env)",
"Write(**/.ssh/**)"
]
}
}File path patterns use GLOB syntax with proper ** globstar support. Each tool type (Read, Write, Edit) has its own patterns - a Read pattern does NOT grant Write or Edit access.
For advanced pattern matching, add extended syntax patterns to .claude/toolguard_hook.toml (preferred) or .claude/toolguard_hook.json.
TOML format (recommended - supports comments):
governed_tools = ["Bash", "mcp__jetbrains__execute_terminal_command"]
[permissions]
# Extended syntax patterns - see Pattern Types section
allow = [
"[regex]^git (log|diff|status|branch)",
"[glob]~/projects/**/*.py",
"[native]docker * --rm *"
]
deny = [
"[regex]rm\\s+-rf\\s+/",
"[glob]**/.env*"
]
ask = [
"Bash(alembic:*)",
"Bash(uv run alembic:*)"
]JSON format:
{
"governed_tools": ["Bash", "mcp__jetbrains__execute_terminal_command"],
"permissions": {
"allow": [
"[regex]^git (log|diff|status|branch)",
"[glob]~/projects/**/*.py",
"[native]docker * --rm *"
],
"deny": [
"[regex]rm\\s+-rf\\s+/",
"[glob]**/.env*"
]
}
}Extended pattern types:
[regex]- Regular expression matching withre.search()[glob]- True glob patterns with proper**globstar support[native]- Claude Code 2.10 word-level wildcard matching
See Pattern Types for detailed syntax and examples.
After configuration, restart Claude Code and check the logs:
- Commands should appear in
logs/toolguard-YYYY-MM-DD.md - If commands aren't logged, verify both hook matcher AND governed_tools include the tool
Toolguard can be configured via environment variables. These can be set in your shell, or in a .env file in your project root.
| Variable | Type | Default | Description |
|---|---|---|---|
TOOLGUARD_LOGGING_ENABLED |
bool | true |
Enable/disable command logging |
TOOLGUARD_LOG_DIR |
path | {project}/logs |
Directory for log files |
TOOLGUARD_EXTENDED_SYNTAX |
bool | true |
Enable [regex], [glob], [native] patterns |
TOOLGUARD_PROJECT_ROOT |
path | (auto-detect) | Explicit project root override |
TOOLGUARD_SOURCE_ROOT |
path | (empty) | Relative path from project root to source root |
TOOLGUARD_CREATE_LOG_DIR |
bool | false |
Auto-create log directory if missing |
Boolean environment variables accept (case-insensitive):
- True:
true,yes,1 - False:
false,no,0
If TOOLGUARD_PROJECT_ROOT is not set, toolguard searches upward from the current directory for:
.gitdirectorypyproject.tomlfile
The first directory containing either marker is used as the project root.
Toolguard loads environment variables from .env at {project_root}/{source_root}/.env. Environment variables set in the shell take precedence over .env file values.
Example .env file:
TOOLGUARD_LOGGING_ENABLED=true
TOOLGUARD_LOG_DIR=logs
TOOLGUARD_CREATE_LOG_DIR=trueMissing log directory: If the log directory doesn't exist:
- With
TOOLGUARD_CREATE_LOG_DIR=false(default): Warning printed to stderr, logging disabled, commands still processed - With
TOOLGUARD_CREATE_LOG_DIR=true: Directory is created automatically
Toolguard supports two categories of pattern matching:
1. Command Patterns (for Bash and terminal tools)
| Pattern Type | Prefix | Matching Method | Use Case |
|---|---|---|---|
| DEFAULT | (none) | fnmatch prefix + path normalization | Standard Claude Code patterns |
| REGEX | [regex] |
re.search() |
Complex matching with regex |
| GLOB | [glob] |
PurePath.full_match() |
File path patterns with globstar |
| NATIVE | [native] |
Word-level segment matching | Claude Code 2.10 wildcard style |
2. File Path Patterns (for Read, Write, Edit tools)
File path tools use GLOB pattern matching exclusively via PurePath.full_match(). This provides proper globstar (**) support that Claude Code's native permissions lack for Write/Edit operations.
The default pattern type uses fnmatch with colon syntax for prefix matching:
Bash(git status:*) # git status with any arguments
Bash(cat ./*:*) # cat files in current directory
Bash(uv run pytest:*) # pytest with any arguments
Bash(git log:*) # git log with any arguments
The :* suffix enables prefix matching - the command must start with the pattern before the colon.
Use [regex] prefix for regular expression matching with re.search():
[regex]^git (log|diff|status) # git log, diff, or status at start
[regex]npm (install|run) # npm install or run anywhere
[regex]^curl -s https?:// # curl with -s flag and http(s) URL
[regex]pytest.*-v # pytest with -v flag anywhere
REGEX patterns match anywhere in the command unless anchored with ^ or $.
Use [glob] prefix for true glob matching with proper globstar (**) support:
[glob]~/projects/**/*.py # Any .py file under ~/projects (recursive)
[glob]/tmp/*.txt # .txt files directly in /tmp only
[glob]/tmp/**/*.txt # .txt files anywhere under /tmp
[glob]~/projects/*/*.js # .js files one level deep only
Important: GLOB patterns properly distinguish * from **:
*matches any characters except path separator/**matches any characters including path separators (recursive)
Use [native] prefix for Claude Code 2.10 wildcard syntax:
[native]git * main # git checkout main, git merge main, etc.
[native]* install # npm install, pip install, cargo install
[native]npm * # Any npm command
[native]git * origin * # git push origin main, git pull origin dev
[native]docker * --rm * # docker run --rm, docker exec --rm, etc.
NATIVE patterns use word-level matching where * matches any sequence of characters. Segments must appear in order.
File path patterns use GLOB syntax with proper ** globstar support:
Read(~/projects/**) # Any file under ~/projects (recursive)
Read(/tmp/**) # Any file under /tmp (recursive)
Read(/tmp/*) # Files directly in /tmp only (not recursive)
Write(~/projects/myapp/**) # Write to any file in myapp project
Write(/tmp/**/*.log) # Write to any .log file under /tmp
Edit(~/projects/**/src/*.py) # Edit .py files in any src directory
Key differences between * and **:
| Pattern | Matches | Does NOT Match |
|---|---|---|
/tmp/* |
/tmp/file.txt |
/tmp/subdir/file.txt |
/tmp/** |
/tmp/file.txt, /tmp/subdir/file.txt, /tmp/a/b/c/d.txt |
/var/tmp/file.txt |
/tmp/**/*.txt |
/tmp/file.txt, /tmp/subdir/file.txt |
/tmp/file.log |
Deny patterns take precedence:
{
"permissions": {
"allow": ["Read(~/projects/**)"],
"deny": ["Read(**/.env)", "Read(**/.env.*)"]
}
}With the above config, toolguard allows reading any file under ~/projects/ EXCEPT .env files anywhere in the path.
Tilde expansion: Both patterns and file paths support tilde (~) expansion. The pattern Read(~/projects/**) will match /Users/username/projects/file.txt.
Toolguard normalizes paths for consistent matching:
| Normalization | Example |
|---|---|
| Tilde conversion | /Users/arnon/projects → ~/projects |
| Symlink resolution | Up to 3 iterations to prevent loops |
| Leading slashes | //tmp → /tmp |
| Relative paths | file.txt → ./file.txt |
Normalization by pattern type:
| Pattern Type | Pattern Normalization | Command Normalization |
|---|---|---|
| DEFAULT | Full | Full |
| GLOB | Tilde expansion only | Tilde expansion only |
| REGEX | None | None |
| NATIVE | None | None |
Toolguard properly handles compound commands with shell operators:
Supported operators: &&, ||, ;, |, &
| Operator | Name | Description |
|---|---|---|
&& |
AND | Run second command only if first succeeds |
|| |
OR | Run second command only if first fails |
; |
Semicolon | Run commands sequentially |
| |
Pipe | Connect stdout of first to stdin of second |
& |
Background | Run command in background |
Behavior:
- Command is parsed and split into sub-commands
- Each sub-command is validated separately
- Strictest response wins:
- If ANY sub-command is denied → whole command denied
- Otherwise if ANY sub-command requires "ask" → whole command asks
- Otherwise all allowed → whole command allowed
Example:
git status && rm -rf / # DENIED - rm -rf is blocked even though git status is allowed
ls -la | grep foo # Both parts must be allowed
sleep 10 & # Background command - sleep is validatedToolguard extracts and validates commands inside substitutions:
| Construct | Example | Status |
|---|---|---|
| Command substitution | $(rm -rf /) |
✅ Inner command extracted and validated |
| Backtick substitution | `rm -rf /` |
✅ Inner command extracted and validated |
| Subshells | (cd /tmp && rm -rf *) |
✅ Inner commands extracted and validated |
| Brace groups | { cmd1; cmd2; } |
✅ Inner commands extracted and validated |
Nested constructs are supported up to 5 levels deep:
echo $(cat $(find . -name '*.txt'))
# All three commands validated: echo, cat, find
(cd /tmp && rm -rf *)
# Both commands validated: cd, rm -rf (denied)The following bash constructs are not currently parsed - their inner commands are treated as opaque:
| Construct | Example | Status |
|---|---|---|
| Process substitution | <(cmd) or >(cmd) |
|
| Control structures | if/for/while/case |
Note: Analysis of historical command logs shows Claude Code rarely generates shell control structures at the start of commands. When if/for/while appear, they are typically inside Python one-liners or awk scripts where they are treated as string arguments rather than shell parsing constructs. This makes the risk of bypassing toolguard via control structures very low in practice.
Mitigation: Use deny patterns that match dangerous commands even when nested:
{
"deny": [
"[regex]rm\\s+-rf",
"[regex]rm\\s+.*-rf"
]
}Takeover Mode is an advanced configuration that allows Claude Code to receive blanket permissions while toolguard acts as the real security gatekeeper. This eliminates permission prompts during Claude Code operation while maintaining full security control.
In takeover mode:
- Claude Code sees blanket allows - No permission prompts interrupt workflow
- Toolguard enforces real permissions - All commands/file operations are validated by toolguard's rules
- Best of both worlds - Uninterrupted workflow with full security control
Use takeover mode when:
- You want Claude to work without permission interruptions
- You trust toolguard's permission system to enforce security
- You have well-defined allow/deny patterns in toolguard configuration
- You want consistent permission enforcement across all tools
Don't use takeover mode when:
- You're still configuring and testing permissions
- You prefer explicit Claude Code permission prompts as a second layer
- You're in a high-security environment requiring multi-layer validation
Takeover mode operates by creating a split configuration:
-
In
.claude/settings.local.json: Add blanket allow patterns that Claude Code sees:{ "permissions": { "allow": [ "Bash(*)", "Read(*)", "Write(*)", "Edit(*)" ] } } -
In
.claude/toolguard_hook.toml: Define real permissions that toolguard enforces:[takeover_mode] enabled = true [permissions] allow = [ "Bash(git status:*)", "Bash(git log:*)", "Read(~/projects/**)", "Write(~/projects/myapp/**)" ] deny = [ "Bash(rm -rf:*)", "Read(**/.env)", "Write(**/.ssh/**)" ]
When takeover_mode.enabled = true, toolguard filters out blanket allow patterns (like Bash(*)) from its configuration to prevent them from bypassing the real permissions you've defined.
[takeover_mode]
# Enable takeover mode (default: false)
enabled = false
# Patterns to ignore from settings.local.json (default list shown)
ignored_allow_patterns = [
"Bash(*)",
"Read(*)",
"Write(*)",
"Edit(*)",
"mcp__jetbrains__execute_terminal_command(*)"
]
# Add custom patterns to ignore list (beyond defaults)
additional_ignored_patterns = []
# What to do when no allow pattern matches (default: "deny")
# Options: "deny" or "ask"
no_match_fallback = "deny"Example with custom patterns:
[takeover_mode]
enabled = true
additional_ignored_patterns = [
"Bash(~/projects/**)", # Ignore overly broad project access
]
no_match_fallback = "ask" # Prompt instead of deny on no matchTo mitigate this risk:
- Test thoroughly before enabling takeover mode
- Monitor logs regularly at
logs/toolguard-YYYY-MM-DD.md - Check error logs at
logs/toolguard-error-YYYY-MM-DD.md - Start with
no_match_fallback = "ask"until you're confident in your patterns - Use deny patterns for critical resources (e.g.,
**/.env,**/.ssh/**)
Verify toolguard is running:
# Commands should appear in daily logs
tail -f logs/toolguard-$(date +%Y-%m-%d).mdIf no logs appear after Claude Code executes commands, toolguard is NOT running and blanket allows are exposed.
Complete takeover mode setup:
.claude/settings.local.json (blanket allows for Claude Code):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
},
{
"matcher": "Read",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
},
{
"matcher": "Write",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
},
{
"matcher": "Edit",
"hooks": [
{ "type": "command", "command": "/path/to/toolguard/run_hook.sh" }
]
}
]
},
"permissions": {
"allow": [
"Bash(*)",
"Read(*)",
"Write(*)",
"Edit(*)"
]
}
}.claude/toolguard_hook.toml (real permissions for toolguard):
governed_tools = ["Bash", "Read", "Write", "Edit"]
[takeover_mode]
enabled = true
no_match_fallback = "deny"
[permissions]
allow = [
# Git operations
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git branch:*)",
# Testing
"Bash(uv run pytest:*)",
# File access
"Read(~/projects/**)",
"Write(~/projects/myapp/**)",
"Edit(~/projects/myapp/**)"
]
deny = [
# Dangerous commands
"Bash(rm -rf:*)",
"Bash(sudo:*)",
"[regex]rm\\s+-rf\\s+/",
# Sensitive files
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/.ssh/**)",
"Write(**/.env)",
"Write(**/.ssh/**)",
"Edit(**/.env)"
]As you work with toolguard, permissions may accumulate in settings.local.json that would be better managed in toolguard_hook.toml. Toolguard provides tools to detect and migrate these permissions automatically.
Config divergence occurs when:
- Permissions exist in
settings.local.jsonbut not intoolguard_hook.toml - You're managing permissions in two places, making maintenance harder
- Extended syntax patterns (
[regex],[glob]) can't be used because they're in the wrong file
- Maintainability: Single source of truth for permissions
- Extended features: Use advanced patterns only available in
toolguard_hook.toml - Clarity: Separation between Claude Code's view (blanket allows) and real permissions
- Takeover mode: Essential for clean takeover mode configuration
Toolguard includes a migration script to detect and migrate permissions:
Dry run (see what would change):
uv run python -m toolguard.scripts.migrate_permissions --dry-runExecute migration:
uv run python -m toolguard.scripts.migrate_permissionsWhat the migration does:
- Scans
settings.local.jsonfor Bash/Read/Write/Edit permissions - Identifies patterns not present in
toolguard_hook.toml - Adds missing patterns to
toolguard_hook.toml - Creates backup before making changes
- Optionally sorts patterns for readability
Example output:
Found 12 patterns in settings.local.json
Found 8 patterns in toolguard_hook.toml
Identified 4 patterns to migrate:
- Bash(git push:*)
- Bash(git pull:*)
- Read(~/projects/docs/**)
- Write(/tmp/**)
Creating backup: logs/config-backups/toolguard_hook-2026-02-05-140523.toml
Migrating patterns...
✓ Migration complete
Enable automatic migration to keep configurations in sync:
[config_sync]
# Enable automatic migration on hook startup
auto_migrate = false
# Directory for configuration backups
backup_dir = "logs/config-backups"
# Sort patterns alphabetically after migration
auto_sort_on_migrate = trueWhen to enable auto-migration:
- You're actively developing permission patterns
- You want new patterns automatically moved to
toolguard_hook.toml - You trust the migration logic (test with dry-run first)
When to disable auto-migration:
- You prefer manual control over migrations
- You're in production with stable permissions
- You want to review changes before applying them
Backup location: logs/config-backups/ (configurable via backup_dir)
Backup naming: toolguard_hook-YYYY-MM-DD-HHMMSS.toml
Example:
logs/config-backups/
├── toolguard_hook-2026-02-05-140523.toml
├── toolguard_hook-2026-02-04-093012.toml
└── toolguard_hook-2026-02-03-151445.toml
Restoring from backup:
# Copy backup to main config
cp logs/config-backups/toolguard_hook-2026-02-05-140523.toml .claude/toolguard_hook.tomlBackup retention: Toolguard does not automatically delete old backups. Clean up manually as needed:
# Keep only last 10 backups
cd logs/config-backups
ls -t | tail -n +11 | xargs rmThe migration script automatically identifies and handles redundant patterns using advanced similarity detection.
Redundant Pattern Removal:
The migration automatically removes patterns from settings.local.json that are:
- Exact duplicates - Pattern exists identically in both files
- Subsets - Pattern is covered by a broader rule in
toolguard_hook.toml
Example:
Found 3 pattern(s) to migrate:
ALLOW:
- Bash(find:*)
- Bash(uv run ruff format:*) ← COVERED BY: Bash(uv run ruff:*)
- Bash(~/bin/open_note_by_title.sh:*)
Found 2 redundant pattern(s) to remove (already in toolguard config):
ALLOW:
- Bash(uv run pytest:*) (exact duplicate)
- Bash(git push:*) (covered by Bash(git:*))
Superset Detection:
For :*) patterns, the migration detects when a broader pattern covers a more specific one:
Bash(uv run ruff:*)coversBash(uv run ruff format:*)Bash(git:*)coversBash(git push:*),Bash(git pull:*), etc.- Only works with simple
:*)postfix patterns (not extended syntax) - Requires word boundary (space) to avoid false positives
Similarity Ranking:
The migration uses Python's difflib to rank similar patterns by similarity score:
Similar patterns (top 3 by similarity):
'Bash(~/bin/open_note_by_title.sh:*)' similar to 'Bash(~/bin/open_note_by_title.sh :*)' (0.97)
'Bash(uv run ruff format:*)' similar to 'Bash(uv run ruff:*)' (0.85) [SUPERSET]
Configuration:
Control similarity detection with the max_similar_matches setting:
[config_sync]
# Maximum similar patterns to show (default: 3)
max_similar_matches = 3Notes:
- Extended syntax patterns (
[regex],[glob],[native]) are skipped for superset detection - If too many patterns share the same prefix, they won't be flagged as similar (not discriminating)
- Similarity uses a 0.7 cutoff threshold to balance precision and recall
Toolguard includes a session-based warning system to alert you about configuration issues without flooding your terminal.
Warning frequency:
- Once per session (default): Warning appears only once per Claude Code session
- Once per day: Warning appears once per calendar day
Warning persistence: Warnings are tracked using marker files in /tmp/toolguard-warnings/
Example workflow:
- Configuration issue detected (e.g., ungoverned tool in permissions)
- Warning printed to stderr and logged to
logs/toolguard-error-YYYY-MM-DD.md - Marker file created at
/tmp/toolguard-warnings/<warning-hash>.marker - Subsequent occurrences of same warning are suppressed until marker expires
Directory: /tmp/toolguard-warnings/
Naming: Each warning type gets a unique hash-based filename:
/tmp/toolguard-warnings/
├── a3f2e1d9c8b7a6f5.marker # Ungoverned tool warning
├── b4e3d2c1f0e9d8c7.marker # TOML/JSON conflict warning
└── c5d4e3f2a1b0c9d8.marker # Unsupported tool warning
Content: Marker files contain the timestamp when the warning was first issued.
Automatic cleanup: Marker files in /tmp/ are typically cleared on system reboot
Manual cleanup (to see warnings again):
# Clear all toolguard warning markers
rm -rf /tmp/toolguard-warnings/
# Clear specific warning (find hash in logs/toolguard-error-YYYY-MM-DD.md)
rm /tmp/toolguard-warnings/a3f2e1d9c8b7a6f5.markerWhen to clear markers:
- You've fixed a configuration issue and want to verify it's resolved
- You want to see all warnings again for debugging
- You're testing warning behavior
Common warnings that use the session warning system:
| Warning Type | Description |
|---|---|
| Ungoverned tools | Tools in permissions but not in governed_tools list |
| Unsupported tools | Tools not recognized by toolguard |
| TOML/JSON conflict | Both .toml and .json config files exist |
| Migration available | Patterns in settings.local.json can be migrated |
All warnings are logged to logs/toolguard-error-YYYY-MM-DD.md regardless of marker status.
Complete TOML configuration structure with all available sections:
# ============================================================================
# TOOLGUARD CONFIGURATION REFERENCE
# ============================================================================
# List of tools that toolguard will govern (required)
governed_tools = [
"Bash",
"Read",
"Write",
"Edit",
"mcp__jetbrains__execute_terminal_command"
]
# Additional custom tools to recognize as valid (optional)
additional_supported_tools = [
"mcp__custom__my_bash_tool"
]
# ============================================================================
# TAKEOVER MODE - Claude sees blanket allows, toolguard enforces real rules
# ============================================================================
[takeover_mode]
# Enable takeover mode (default: false)
enabled = false
# Blanket patterns to ignore from settings.local.json (these are defaults)
ignored_allow_patterns = [
"Bash(*)",
"Read(*)",
"Write(*)",
"Edit(*)",
"mcp__jetbrains__execute_terminal_command(*)"
]
# Additional patterns to ignore (beyond defaults)
additional_ignored_patterns = []
# Fallback when no pattern matches: "deny" or "ask" (default: "deny")
no_match_fallback = "deny"
# ============================================================================
# CONFIG SYNC - Automatic migration from settings.local.json
# ============================================================================
[config_sync]
# Enable automatic migration on startup (default: false)
auto_migrate = false
# Directory for configuration backups (default: "logs/config-backups")
backup_dir = "logs/config-backups"
# Sort patterns after migration (default: true)
auto_sort_on_migrate = true
# ============================================================================
# PERMISSIONS - Define allow/deny/ask patterns
# ============================================================================
[permissions]
# Allowed patterns - commands/files that are permitted
allow = [
# Standard patterns (Claude Code compatible)
"Bash(git status:*)",
"Bash(git log:*)",
"Bash(uv run pytest:*)",
"Read(~/projects/**)",
"Write(~/projects/myapp/**)",
# Extended syntax patterns (toolguard only)
"[regex]^git (status|log|diff|branch)",
"[glob]~/projects/**/*.py",
"[native]docker * --rm *"
]
# Denied patterns - explicitly blocked (take precedence over allow)
deny = [
# Dangerous commands
"Bash(rm -rf:*)",
"Bash(sudo:*)",
"[regex]rm\\s+-rf\\s+/",
# Sensitive files
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/.ssh/**)",
"Write(**/.env)",
"Write(**/.ssh/**)",
"Edit(**/.env)"
]
# Ask patterns - require explicit user confirmation (optional)
ask = [
"Bash(alembic:*)",
"Bash(uv run alembic:*)",
"Write(~/projects/production-db/**)"
]Configuration notes:
- Comments: TOML format supports inline comments (not available in JSON)
- Pattern order: Patterns within each section (allow/deny/ask) are checked in order
- Deny precedence: Deny patterns always take precedence over allow patterns
- Extended syntax: Only supported in
toolguard_hook.toml, not insettings.local.json
// DANGEROUS - DO NOT USE unless takeover mode is enabled
{
"permissions": {
"allow": ["Bash(*)", "Write(*)"]
}
}Why this is dangerous:
- Claude can execute ANY command or write ANY file
- No protection against mistakes or malicious suggestions
- Bypasses all security controls
Safe approaches:
-
Specific patterns only (no takeover mode):
{ "permissions": { "allow": [ "Bash(git status:*)", "Write(~/projects/myapp/**)" ] } } -
Takeover mode with toolguard (blanket allows OK):
# In settings.local.json: Bash(*), Write(*) # In toolguard_hook.toml: [takeover_mode] enabled = true [permissions] allow = ["Bash(git status:*)", "Write(~/projects/myapp/**)"] deny = ["Bash(rm -rf:*)", "Write(**/.env)"]
Always test changes with backups:
-
Before enabling takeover mode:
# Backup your configs cp .claude/settings.local.json .claude/settings.local.json.backup cp .claude/toolguard_hook.toml .claude/toolguard_hook.toml.backup -
Before running migration:
# Dry run first uv run python -m toolguard.scripts.migrate_permissions --dry-run # Migration creates automatic backup, but manual backup doesn't hurt cp .claude/toolguard_hook.toml logs/manual-backup-$(date +%Y-%m-%d).toml
-
Regular backups:
# Weekly backup script cp .claude/toolguard_hook.toml ~/backups/toolguard-$(date +%Y-%m-%d).toml
Always test migrations before executing:
# Step 1: See what would change
uv run python -m toolguard.scripts.migrate_permissions --dry-run
# Step 2: Review output carefully
# - Are the patterns correct?
# - Any unexpected migrations?
# - Similar patterns that should be consolidated?
# Step 3: Execute only if dry-run looks good
uv run python -m toolguard.scripts.migrate_permissions
# Step 4: Verify the result
diff .claude/toolguard_hook.toml logs/config-backups/toolguard_hook-*.tomlVerify toolguard is working:
-
Check logs after commands:
tail -20 logs/toolguard-$(date +%Y-%m-%d).mdYou should see entries for every command Claude executes.
-
Monitor error logs:
tail -f logs/toolguard-error-$(date +%Y-%m-%d).mdWatch for warnings about configuration issues.
-
Test with intentional violation:
# If "rm -rf /" is denied, this command should be blocked # Try it via Claude Code and verify it's logged as refused
Red flags that toolguard is NOT working:
- No entries in
logs/toolguard-YYYY-MM-DD.mdafter Claude executes commands - Commands execute that should be denied
- No warnings/errors in logs when you expect them
Always include these in your deny list:
[permissions]
deny = [
# Destructive commands
"Bash(rm -rf:*)",
"[regex]rm\\s+-rf\\s+/",
"Bash(dd:*)",
# Privilege escalation
"Bash(sudo:*)",
"Bash(su:*)",
# Sensitive files
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/.aws/**)",
"Read(**/.ssh/**)",
"Write(**/.env)",
"Write(**/.aws/**)",
"Write(**/.ssh/**)",
"Edit(**/.env)",
# System directories
"Write(/etc/**)",
"Write(/usr/**)",
"Write(/bin/**)",
"Write(/sbin/**)"
]toolguard/ # Project root
├── pyproject.toml # Package metadata (hatchling build backend)
├── run_hook.sh # Wrapper script for Claude Code hook configuration
├── toolguard/ # Python package
│ ├── __init__.py
│ ├── hook.py # Main hook entry point (reads stdin, writes stdout)
│ ├── config.py # Configuration loading and merging
│ ├── config_validation.py # Validates tool permissions at startup
│ ├── toml_config.py # TOML configuration loader
│ ├── env_config.py # Environment configuration (.env loading)
│ ├── error_log.py # Warning/error logging to toolguard-error-*.md
│ ├── permissions.py # Permission checking logic
│ ├── patterns.py # Pattern type parsing and matching
│ ├── normalization.py # Path normalization functions
│ ├── compound.py # Compound command handling
│ ├── log_writer.py # Command logging to markdown/JSONLines files
│ ├── auto_migrate.py # Config auto-migration logic
│ ├── config_divergence.py # Config divergence detection
│ ├── session_warnings.py # Session-level warnings (e.g., takeover mode)
│ ├── subagent.py # Agent context identification
│ ├── validation.py # Input validation utilities
│ ├── parser/ # PEG-based bash command parser
│ │ ├── __init__.py
│ │ ├── bash_parser.peg # Grammar definition
│ │ ├── bash_parser.py # Canopy-generated parser
│ │ └── command_extractor.py # Command extraction from parsed AST
│ └── scripts/ # Utility scripts
│ └── migrate_permissions.py
└── test/ # Tests (at project root, not inside package)
└── unit/
├── test_auto_migrate.py
├── test_bash_parser.py
├── test_compound.py
├── test_config.py
├── test_config_divergence.py
├── test_env_config.py
├── test_hook.py
├── test_log_writer.py
├── test_migration.py
├── test_normalization.py
├── test_patterns.py
├── test_permissions.py
├── test_session_warnings.py
├── test_takeover_mode.py
└── test_toml_config.py
┌─────────────────┐
│ Claude Code │
│ PreToolUse │
└────────┬────────┘
│ JSON via stdin
▼
┌─────────────────┐
│ run_hook.sh │──► python -m toolguard.hook
│ parse_input() │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Is tool in │──No──► Allow (not governed)
│ governed list? │
└────────┬────────┘
│ Yes
▼
┌─────────────────────────┐
│ Tool type? │
└────────┬────────────────┘
│
┌────┴────┐
▼ ▼
┌───────┐ ┌───────────┐
│ File │ │ Command │
│ Path │ │ Tool │
│ Tool │ │(Bash,etc) │
└───┬───┘ └─────┬─────┘
│ │
▼ ▼
┌─────────┐ ┌─────────────┐
│ GLOB │ │ compound.py │
│ match │ │ Parse cmd │
│ via │ │ into parts │
│PurePath │ └──────┬──────┘
│.full_ │ │
│ match() │ ▼
└────┬────┘ ┌─────────────┐
│ │permissions │
│ │.py Check │
│ │each subcmd │
│ └──────┬──────┘
│ │
└──────┬──────┘
▼
┌─────────────────┐
│ logging.py │
│ Log decision │
└────────┬────────┘
│ JSON via stdout
▼
┌─────────────────┐
│ Claude Code │
│ Execute/Block │
└─────────────────┘
Toolguard follows Claude Code's configuration hierarchy:
CLAUDE_SETTINGS_PATHenvironment variable (if set, takes priority)- Otherwise, merges from multiple sources in order:
- Project local:
.claude/toolguard_hook.local.toml(or.json) - Project local:
.claude/settings.local.json - Project:
.claude/toolguard_hook.toml(or.json) - Project:
.claude/settings.json - User local:
~/.claude/toolguard_hook.local.toml(or.json) - User local:
~/.claude/settings.local.json - User:
~/.claude/toolguard_hook.toml(or.json) - User:
~/.claude/settings.json
- Project local:
TOML precedence: When both .toml and .json files exist at the same level (e.g., both toolguard_hook.toml and toolguard_hook.json), the TOML file takes precedence and a warning is logged.
Extended patterns ([regex], [glob], [native]) are only supported in toolguard_hook.toml or toolguard_hook.json files to avoid polluting native Claude configuration.
DEFAULT (permissions.py):
- Uses
fnmatch.fnmatch()with colon syntax - Applies full path normalization to both pattern and command
- Handles special path component patterns like
**/.env/**
REGEX (patterns.py):
- Uses
re.search()for flexible matching anywhere in command - No normalization applied
- Invalid regex patterns treated as non-matching
GLOB (patterns.py):
- Uses
PurePath.full_match()(Python 3.13+) for proper globstar *matches single path level,**matches recursively- Tilde expansion applied to both pattern and command
NATIVE (patterns.py):
- Splits pattern by
*into literal segments - Finds segments in order within command
- Handles leading/trailing wildcards for anchoring
Read, Write, Edit (hook.py):
- Uses
PurePath.full_match()for GLOB matching - Patterns extracted from settings via
load_file_path_patterns() - Pattern syntax:
ToolName(pattern)e.g.,Read(/tmp/**) - Tilde expansion applied to both patterns and file paths
- Deny patterns checked first (take precedence)
- Each tool type has separate patterns (Read pattern ≠ Write permission)
All decisions are logged to logs/toolguard-YYYY-MM-DD.md (or .jsonlines) with:
- Timestamp
- Operation (command or file path with tool name)
- Decision (allow/deny)
- Matched Rule (for allowed commands: the pattern that permitted it)
- Violated Rules (for denied commands: the pattern or reason for denial)
- Agent identification
For compound commands (e.g., git status && git log), each sub-command is logged as a separate entry with its own matched rule.
Example log entries (markdown format):
## 2026-01-14 10:15:23
- **Status**: EXECUTED
- **Command**: `git status`
- **Matched Rule**: `git *`
- **Agent**: main
## 2026-01-14 10:15:45
- **Status**: EXECUTED
- **Command**: `Read(/tmp/test.txt)`
- **Matched Rule**: `/tmp/**`
- **Agent**: main
## 2026-01-14 10:16:02
- **Status**: REFUSED
- **Command**: `Write(/etc/passwd)`
- **Violated Rules**: `Path does not match any allow patterns`
- **Agent**: mainExample (JSONLines format):
{"timestamp": "2026-01-14T10:15:23", "status": "executed", "command": "git status", "violated_rules": [], "matched_rule": "git *", "extra_info": "main"}Configuration issues and validation warnings are logged to a separate file: logs/toolguard-error-YYYY-MM-DD.md
What gets logged:
- WARNING: Unsupported tools in permissions (tools not in known list or
additional_supported_tools) - WARNING: Ungoverned tools in permissions (tools in known list but not in
governed_tools) - WARNING: Both TOML and JSON config files exist at the same level
- ERROR: Critical configuration problems
Example error log entry:
## 2026-01-20 10:30:45 - WARNING
**Message**: Tool "WebSearch" is not a known supported tool
**Corrective Steps**: If "WebSearch" is a valid tool that should be governed, add it to "additional_supported_tools" in your config. Otherwise, remove it from permissions or update the tool name.
---Note: Warnings are also printed to stderr, so you'll see them in your terminal when the hook first runs.
- Python >= 3.14 (for
PurePath.full_match()globstar support) - uv (recommended) or pip for package management
- Claude Code with PreToolUse hook support
Toolguard uses hatchling as its build backend. To install in development mode:
cd /path/to/toolguard
uv pip install -e .To add toolguard as an editable dependency in another project (e.g., via uv):
# In the consuming project's pyproject.toml
[project]
dependencies = ["toolguard"]
[tool.uv.sources]
toolguard = { path = "/path/to/toolguard", editable = true }Then run uv sync in the consuming project.
cd /path/to/toolguard
# Run all toolguard tests
uv run python -m unittest discover -s test/unit -v
# Run a specific test file
uv run python -m unittest discover -s test/unit -p "test_patterns.py" -vCurrent test coverage: 538 tests covering all pattern types, compound commands, command substitution extraction, subshell extraction, brace group extraction, file path permissions, configuration, TOML configuration, config validation, config divergence, auto-migration, error logging, environment variables, matched rule logging, session warnings, takeover mode, security bypass attempts, parser robustness, and edge cases.