A security hook for Claude Code's PreToolUse Bash Tool.
Designed for unattended agent runs (non-interactive sessions), it ensures safer automated operations by providing:
- Robust bash command filtering
- Advanced bypass prevention
- Intelligent path access control
Hook Type: PreToolUse (Bash Tool)
Operation Mode: Fully automated, no user interaction
Target Users: Developers using Claude Code (unattended / automated workflows)
This tool provides defense-in-depth security for Claude Code bash operations, but is not infallible.
While Claude Code Bash Guardian has been extensively tested against common attack patterns and bypass techniques, no security tool can guarantee 100% protection against all possible threats. Key considerations:
- Residual Risk: Despite comprehensive testing, false negatives (dangerous commands being allowed) remain theoretically possible due to the complexity of bash syntax and potential zero-day bypass techniques
- Threat Model: The primary threat actor is an AI agent making mistakes or being prompted inappropriately, not a deliberate attacker with system access
- Risk-Benefit Analysis: The security benefits of filtering automated agent commands significantly outweigh the residual risks in most development environments
- Best Practices:
- Always maintain current backups of critical data and code
- Use version control (git) to track and revert unwanted changes
- Run in isolated environments when working with sensitive systems
- Review logs periodically
- Test custom configurations thoroughly before production use
This tool is provided "as is" under the MIT License, without warranty of any kind. Users assume all risks associated with its deployment and operation.
- Enhanced Wrapper Command Detection: Intelligently detects and scans wrapper commands (
timeout
,xargs
,sudo
, etc.) - Variable Command Prevention: Blocks
$var
,$(cmd)
,`cmd`
as command names - Script Injection Protection: Prevents
echo "rm -rf /" > script.sh && bash script.sh
attacks - Intelligent Path Normalization: Smart path resolution and matching
- Complex Structure Support: Detects dangerous commands in
for
/while
/if
statements - Absolute Path Resolution: Path traversal attacks (
../../etc/passwd
) are blocked - Comprehensive Blacklisting: 15+ categories of dangerous command patterns with bypass prevention
- Flexible sudo Configuration: Configurable privilege escalation control
Install required Python dependencies (PyYAML
and bashlex
):
pip3 install PyYAML bashlex
conda install -c conda-forge pyyaml bashlex
poetry add PyYAML bashlex
Note: Customize the configuration to match your specific security requirements and workflow. The default settings provide a balanced baseline, but you should adjust them based on your needs.
Deploy the permission system across all Claude Code projects:
# 1. Copy files to global Claude directory
cp claude_code_bash_guardian.py ~/.claude/hooks/
cp claude_code_bash_guardian_config.yaml ~/.claude/hooks/
chmod +x ~/.claude/hooks/claude_code_bash_guardian.py
# 2. Configure global settings
# If you already have a ~/.claude/settings.json file, merge the following configuration:
# The hook should be added to the existing "hooks.PreToolUse" array.
# For new installation (no existing settings.json):
cat > ~/.claude/settings.json << 'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude_code_bash_guardian.py"
}
]
}
]
}
}
EOF
Deploy the permission system for a specific project:
# 1. Create project hooks directory
mkdir -p your_project/.claude/hooks/
# 2. Copy files to project directory
cp claude_code_bash_guardian.py your_project/.claude/hooks/
cp claude_code_bash_guardian_config.yaml your_project/.claude/hooks/
chmod +x your_project/.claude/hooks/claude_code_bash_guardian.py
# 3. Configure project settings
# If your project already has .claude/settings.json, merge the hook configuration.
# Otherwise, create a new settings.json with:
cat > your_project/.claude/settings.json << 'EOF'
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/claude_code_bash_guardian.py"
}
]
}
]
}
}
EOF
The claude_code_bash_guardian_config.yaml
file controls all security policies.
forbidden_env_vars: # Block dangerous environment variables
- LD_PRELOAD
- PATH
forbidden_pipe_targets: # Block dangerous pipe targets
- sh
- bash
multi_level_commands: # Commands with subcommands
- git
- docker
wrapper_commands: # Commands that can execute other commands
- sudo
- timeout
- xargs
- env
- nice
xargs echo rm -rf
(which just prints text) will be blocked because it contains a dangerous pattern. This is intentional - the system prioritizes security over convenience.
command_blacklist:
# Simple commands
- sudo # Remove to allow sudo as wrapper only
- rm
# Commands with parameters
- git reset --hard # Exact match for long flags
- rm -r # Flexible match for short flags
- git clean -fd # AND logic: needs both -f and -d
# Path-agnostic blocking
- bash # Blocks from any path (/bin/bash, ./bash, etc.)
- sh
Parameter Matching Rules:
- Long flags (
--hard
): Exact match required - Short flags (
-r
): Flexible combination (-rf
,-fr
,-f -r
all match) - Multiple parameters (
-fd
): AND logic - all must be present - Path-agnostic: Commands like
bash
are blocked from any path
security_options:
allow_external_path_access: false # Restrict access outside project
external_read_exception_commands: # Can read from external paths
- cd, ls, cat, grep, find, diff # (see config for full list)
external_copy_exception_commands: # Can copy FROM external paths
- cp, ln, rsync
allow_variable_commands: false # Block $var, $(cmd), `cmd` as commands
Path Access Rules:
- By default, commands can only access project directory and
/tmp
- Exception commands can access external paths for reading or copying
/dev/*
special files are always allowed (null, stdout, stderr, etc.)- Uses balance check: total external paths must equal exception command paths
Variable Command Control:
- When disabled: blocks variables as commands but allows as arguments
- When enabled: permits all variable usage (less secure)
# Script injection - BLOCKED
echo "rm -rf /" > cmd.sh && bash cmd.sh
# Path traversal - BLOCKED
cp file ../../etc/shadow
# Variable as command - BLOCKED
cmd="rm"; $cmd file
# Variable as argument - ALLOWED
echo $USER
system_config:
debug_mode: false
log_denials: true # Log blocked commands to logs/permission_denials.json
log_approvals: false # Log allowed commands to logs/permission_approvals.json
The system automatically determines your project root using this priority order:
- Script location: If the hook is in
.claude/hooks/
, uses that project root - Outermost .claude: Uses the highest-level
.claude
directory found - Git repository: Falls back to
.git
repository root - Current directory: Uses current working directory if no markers found
Boundaries: Detection stops at home (~
) or root (/
) directory.
When allow_external_path_access: true
, detection is skipped entirely.
This section provides detailed implementation information for developers who want to understand how the system works internally.
The permission system uses a modular architecture with clear separation of concerns:
graph TD
A[Command Input] --> B[ClaudeBashGuardian<br/>Main Orchestrator]
B --> C[ConfigManager<br/>Configuration & Settings]
B --> D[ProjectDetector<br/>Project Root Detection]
B --> E[CommandParser<br/>AST Parsing]
E --> F[CommandContext<br/>Parsed Data Container]
F --> G[Security Check Pipeline]
G --> H[EnvironmentVariableCheck]
G --> I[PipeSecurityCheck]
G --> J[VariableCommandCheck]
G --> K[BlacklistCheck]
G --> L[PathAccessCheck]
H --> M[CheckResult]
I --> M
J --> M
K --> M
L --> M
M --> N{All Checks Passed?}
N -->|No| O[Logger<br/>Log Decision]
N -->|Yes| O
O --> P[Structured Response]
P --> Q[Exit Code 0/2]
Core Components:
- ClaudeBashGuardian: Main orchestrator that coordinates all components
- ConfigManager: Handles configuration loading, merging, and access
- ProjectDetector: Determines project root directory using priority-based detection
- CommandParser: Parses bash commands into AST and extracts structured data
- Logger: Manages security decision logging
Data Models:
- CommandContext: Container for parsed command data (AST, paths, arguments)
- CheckResult: Standardized result object for security checks
Security Checks (all extend SecurityCheck base class):
- EnvironmentVariableCheck: Validates environment variable usage
- PipeSecurityCheck: Checks for dangerous pipe targets
- VariableCommandCheck: Prevents variable command execution
- BlacklistCheck: Matches against command blacklist patterns
- PathAccessCheck: Controls external path access
- Single Responsibility: Each class has one clear purpose
- Dependency Injection: Components receive dependencies through constructors
- Open/Closed: New security checks can be added without modifying existing code
- Composition over Inheritance: Components are composed rather than deeply inherited
- Fail-Safe Defaults: Security decisions default to deny when uncertain
The system uses bashlex to parse bash commands into Abstract Syntax Trees (AST) for all security checks. This AST-based approach provides several advantages over regex-based parsing:
- Context Awareness: Understands shell quoting, escaping, and command structure
- Normalization: Bashlex automatically normalizes obfuscated patterns (e.g.,
ba'sh'
→bash
) - Accurate Detection: Distinguishes between actual commands and strings (e.g.,
echo 'rm -rf /'
is safe)
For compound commands like ls /tmp/ && cp /etc/passwd ./local && touch ~/file
, bashlex generates a nested tree structure containing:
- ListNode: The top-level container for compound commands
- CommandNode: Individual command representations
- WordNode: Command arguments and parameters
- OperatorNode: Logical operators like
&&
,||
,;
- PipelineNode: Pipe operations between commands
- AssignmentNode: Environment variable assignments
The security checks traverse this AST to identify:
- Pipe targets: Detected from PipelineNode structures, not string patterns
- Environment variables: Found in AssignmentNode and command arguments
- Variable commands: Identified by ParameterNode and CommandsubstitutionNode in command position
The CommandParser class performs single-pass data extraction from the AST, creating a CommandContext object containing:
- individual_commands: List of isolated commands (e.g.,
["ls /tmp/", "cp /etc/passwd ./local", "touch ~/file"]
) - all_paths: All file paths found in the command (e.g.,
["/tmp/", "/etc/passwd", "./local", "~/file"]
) - all_arguments: Flattened list of all command parts for comprehensive analysis
- ast_tree: The original AST for security checks to traverse
- project_root: The detected project root directory for path resolution
The BlacklistCheck class employs two distinct strategies based on wrapper command detection:
Strategy 1: Direct Matching (No wrapper detected)
- Applied when the first command is not a wrapper
- Direct pattern matching at position 0
- Standard command and argument verification
Strategy 2: Scanning Strategy (Wrapper detected)
- Triggered for commands like
timeout
,xargs
,env
- Three-phase checking:
- Wrapper check: Verify if wrapper itself is blacklisted
- Single command scan: For patterns like "sudo", scan all arguments
- Multi-part scan: For patterns like "rm -r", find command then verify arguments
Parameter Matching Implementation:
- Long flags (
--hard
): String exact match in arguments list - Short flags (
-r
): Character-level matching, handles combinations like-rf
,-fr
,-f -r
- Multiple parameters (
-fd
): AND logic ensures all required flags are present - Path normalization: Commands are matched by basename for system paths, relative path for project paths
Example Matching Flow:
Command: timeout 5 rm -rf /tmp/test
1. Detect 'timeout' as wrapper
2. Scan arguments for 'rm'
3. Find 'rm' at position 2
4. Check if '-r' flag present (found in '-rf')
5. Block command
This scanning approach deliberately produces false positives (e.g., xargs echo rm -rf
is blocked even though it just prints text) to ensure dangerous patterns never execute.
The PathAccessCheck class uses a unified counting-based balance check for all external path access:
graph TD
A[Extract All Paths Including Redirects] --> B[Count ALL External Paths]
B --> C[Count Exception Command Externals]
C --> D[Read Exception Commands]
C --> E[Copy Exception Commands<br/>destination not counted]
D --> F[Sum All Exceptions]
E --> F
F --> G{external_cnt == exception_cnt?}
G -->|Yes| H[Balanced - Allow]
G -->|No| I[Unbalanced - Deny]
subgraph "Counting Logic"
J[external_cnt: ALL external paths found]
K[exception_cnt: Paths used by exception commands]
end
Implementation Details:
- External Path Detection: Paths outside project root and /tmp are considered external
- Read Exception Commands: All non-flag arguments count as potential external paths
- Copy Exception Commands: Only source paths (all except last argument) count
- Special Files:
/dev/null
,/dev/stdout
,/dev/stderr
, etc. are never counted as external - Redirects: Output redirects are automatically detected and counted
- Balance Rule: Total external paths MUST equal exception command paths
Example Balance Calculation:
Command: cat /etc/passwd > /external/output
- External paths found: 2 (/etc/passwd, /external/output)
- Read exception paths: 1 (cat can read /etc/passwd)
- Balance: 2 ≠ 1 → DENIED
The VariableCommandCheck prevents dynamic command execution:
- Variable expansion:
$cmd
,${cmd}
- Command substitution:
$(command)
,`command`
- Special commands:
eval
,source
,.
with variables
Detection occurs at AST level:
- ParameterNode or CommandsubstitutionNode in command position → blocked
- Same nodes in argument position → allowed
This enables smart detection that blocks $cmd server
but allows echo $USER
.
The system uses a sophisticated 4-priority algorithm to determine the project root:
graph TD
A[Start from Current Directory] --> B[Single Traversal to Collect Info]
B --> C[Traverse Upward Until HOME or Root]
C --> D[Collect All .claude Directories]
C --> E[Record First .git Directory]
C --> F[Priority Decision Phase]
F --> G[Priority 1: Script Under .claude?]
G -->|Yes| H[Use That Project Root]
G -->|No| I[Priority 2: Outermost .claude?]
I -->|Yes| J[Use Outermost .claude Root]
I -->|No| K[Priority 3: .git Directory?]
K -->|Yes| L[Use .git Root]
K -->|No| M[Priority 4: Current Directory]
Priority Levels Explained:
-
Script Under .claude Directory: When the hook script itself is located under a
.claude/hooks/
directory, use that project as root. This ensures project-specific deployments work correctly. -
Outermost .claude Directory: If
.claude
directories are found during traversal but the script isn't in any of them, use the outermost (highest-level) one. This handles nested project structures. -
Git Repository Root: If no
.claude
directories exist, fall back to the.git
repository root. This provides sensible defaults for most development projects. -
Current Directory: If no project markers are found, conservatively use the current working directory to avoid accidentally granting broad access.
Traversal Boundaries:
- HOME Boundary: Stops at
~/
directory, doesn't check~/.claude
- Root Boundary: Stops at
/
directory, doesn't check/.claude
- Performance Optimization: When
allow_external_path_access: true
, the entire detection is skipped
To add a new security check:
- Create a new class extending
SecurityCheck
:
class MyCustomCheck(SecurityCheck):
def check(self, context: CommandContext) -> CheckResult:
# Implement your security logic
if self.is_dangerous(context):
return CheckResult.deny("Custom check failed")
return CheckResult.allow()
- Add to the check pipeline in
ClaudeBashGuardian.__init__()
:
self.checks = [
EnvironmentVariableCheck(self.config),
# ... existing checks ...
MyCustomCheck(self.config) # Add your check
]
- Configure in YAML if needed:
my_custom_settings:
forbidden_patterns: [...]
Despite the AST-based improvements, certain patterns still cannot be fully analyzed:
- Loop body commands:
for i in $(ls /etc); do rm $i; done
- Commands inside loop structures aren't fully detected - Find -exec parameters:
find /etc -exec rm {} \;
- The -exec parameter isn't analyzed - Complex constructs not fully analyzed:
echo "import os; os.system('rm -rf /')" | python3
- Interpreter not in forbidden listcat script | dash
- Alternative shell not in forbidden listecho "test" | awk 'system("ls /etc")'
- awk can execute system commandscat file | tee >(bash)
- Process substitution syntax not fully parsed by bashlex
This project is licensed under the MIT License - see the LICENSE file for details.
This project was developed with assistance from Claude AI (Anthropic) for implementation, testing, and documentation. Human guidance was provided for requirements specification, security design decisions, and validation.