2f321fb2aa
* Fix: Security allowlist not working in worktree mode
This fixes three related bugs that prevented .auto-claude-allowlist from working in isolated workspace (worktree) mode:
1. Security hook reads from wrong directory
- Hook used os.getcwd() which returns main project dir, not worktree
- Added AUTO_CLAUDE_PROJECT_DIR env var set by agent on startup
- Files: security/hooks.py, agents/coder.py, qa/loop.py
2. Security profile cache doesn't track allowlist changes
- Cache only tracked .auto-claude-security.json mtime
- Now also tracks .auto-claude-allowlist mtime
- File: security/profile.py
3. Allowlist not copied to worktree
- .env files were copied but not security config files
- Now copies both .auto-claude-allowlist and .auto-claude-security.json
- File: core/workspace/setup.py
Impact: Custom commands (cargo, dotnet, etc.) were always blocked in worktree mode even with proper allowlist configuration.
Tested on Windows with Rust project (cargo commands).
* Address Gemini Code Assist review comments
- hooks.py: Add input_data.get("cwd") back to priority chain (HIGH)
- coder.py: Move import os to top of file (MEDIUM)
- loop.py: Move import os to top of file (MEDIUM)
- profile.py: Remove redundant exists() check, catch FileNotFoundError (MEDIUM)
- setup.py: Refactor security files copying with loop (MEDIUM)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add clarifying comment for security file overwrite behavior
Addresses CodeRabbit review comment explaining why security files
always overwrite (unlike env files) - prevents security bypasses.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: Add security commands configuration guide
Explains the security system for command validation:
- How automatic stack detection works
- When and how to use .auto-claude-allowlist
- Troubleshooting common issues
- Worktree mode behavior
This helps users understand why commands may be blocked
and how to properly configure custom commands.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add error handling for security file copy
Addresses CodeRabbit review: wrap shutil.copy2 in try/except
to provide clear error messages instead of crashing on
permission or disk space issues.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: Fix markdown formatting nitpicks
- Add 'text' language specifier to ASCII diagram code block
- Add 'text' language specifier to allowlist example
- Add blank line before code fence in troubleshooting section
Addresses CodeRabbit trivial review comments.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: Use shared constants for security filenames and env var
Addresses Auto Claude PR Review findings:
MEDIUM:
- setup.py: Use ProjectAnalyzer.PROFILE_FILENAME and
StructureAnalyzer.CUSTOM_ALLOWLIST_FILENAME instead of magic strings
- profile.py: Use StructureAnalyzer.CUSTOM_ALLOWLIST_FILENAME
LOW:
- Create security/constants.py with PROJECT_DIR_ENV_VAR
- Use constant in hooks.py, coder.py, loop.py
- Expand worktree documentation to explain overwrite behavior
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: Centralize security filenames in constants.py
Move ALLOWLIST_FILENAME and PROFILE_FILENAME to security/constants.py
for better cohesion. All security-related constants are now in one place.
- setup.py: Import from security.constants
- profile.py: Import from .constants (same module)
Addresses CodeRabbit review suggestion.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* style: Simplify exception handling (FileNotFoundError is subclass of OSError)
* style: Fix import sorting order (ruff I001)
* style: fix ruff formatting issues
- Add blank line after import inside function (hooks.py)
- Split global statements onto separate lines (profile.py)
- Reformat long if condition with `and` at line start (profile.py)
- Break long print_status line (setup.py)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Arcker <Arcker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
176 lines
5.3 KiB
Python
176 lines
5.3 KiB
Python
"""
|
|
Security Hooks
|
|
==============
|
|
|
|
Pre-tool-use hooks that validate bash commands for security.
|
|
Main enforcement point for the security system.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from project_analyzer import BASE_COMMANDS, SecurityProfile, is_command_allowed
|
|
|
|
from .parser import extract_commands, get_command_for_validation, split_command_segments
|
|
from .profile import get_security_profile
|
|
from .validator import VALIDATORS
|
|
|
|
|
|
async def bash_security_hook(
|
|
input_data: dict[str, Any],
|
|
tool_use_id: str | None = None,
|
|
context: Any | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Pre-tool-use hook that validates bash commands using dynamic allowlist.
|
|
|
|
This is the main security enforcement point. It:
|
|
1. Validates tool_input structure (must be dict with 'command' key)
|
|
2. Extracts command names from the command string
|
|
3. Checks each command against the project's security profile
|
|
4. Runs additional validation for sensitive commands
|
|
5. Blocks disallowed commands with clear error messages
|
|
|
|
Args:
|
|
input_data: Dict containing tool_name and tool_input
|
|
tool_use_id: Optional tool use ID
|
|
context: Optional context
|
|
|
|
Returns:
|
|
Empty dict to allow, or {"decision": "block", "reason": "..."} to block
|
|
"""
|
|
if input_data.get("tool_name") != "Bash":
|
|
return {}
|
|
|
|
# Validate tool_input structure before accessing
|
|
tool_input = input_data.get("tool_input")
|
|
|
|
# Check if tool_input is None (malformed tool call)
|
|
if tool_input is None:
|
|
return {
|
|
"decision": "block",
|
|
"reason": "Bash tool_input is None - malformed tool call from SDK",
|
|
}
|
|
|
|
# Check if tool_input is a dict
|
|
if not isinstance(tool_input, dict):
|
|
return {
|
|
"decision": "block",
|
|
"reason": f"Bash tool_input must be dict, got {type(tool_input).__name__}",
|
|
}
|
|
|
|
# Now safe to access command
|
|
command = tool_input.get("command", "")
|
|
if not command:
|
|
return {}
|
|
|
|
# Get the working directory from context or use current directory
|
|
# Priority:
|
|
# 1. Environment variable PROJECT_DIR_ENV_VAR (set by agent on startup)
|
|
# 2. input_data cwd (passed by SDK in the tool call)
|
|
# 3. Context cwd (should be set by ClaudeSDKClient but sometimes isn't)
|
|
# 4. Current working directory (fallback, may be incorrect in worktree mode)
|
|
from .constants import PROJECT_DIR_ENV_VAR
|
|
|
|
cwd = os.environ.get(PROJECT_DIR_ENV_VAR)
|
|
if not cwd:
|
|
cwd = input_data.get("cwd")
|
|
if not cwd and context and hasattr(context, "cwd"):
|
|
cwd = context.cwd
|
|
if not cwd:
|
|
cwd = os.getcwd()
|
|
|
|
# Get or create security profile
|
|
# Note: In actual use, spec_dir would be passed through context
|
|
try:
|
|
profile = get_security_profile(Path(cwd))
|
|
except Exception as e:
|
|
# If profile creation fails, fall back to base commands only
|
|
print(f"Warning: Could not load security profile: {e}")
|
|
profile = SecurityProfile()
|
|
profile.base_commands = BASE_COMMANDS.copy()
|
|
|
|
# Extract all commands from the command string
|
|
commands = extract_commands(command)
|
|
|
|
if not commands:
|
|
# Could not parse - fail safe by blocking
|
|
return {
|
|
"decision": "block",
|
|
"reason": f"Could not parse command for security validation: {command}",
|
|
}
|
|
|
|
# Split into segments for per-command validation
|
|
segments = split_command_segments(command)
|
|
|
|
# Get all allowed commands
|
|
allowed = profile.get_all_allowed_commands()
|
|
|
|
# Check each command against the allowlist
|
|
for cmd in commands:
|
|
# Check if command is allowed
|
|
is_allowed, reason = is_command_allowed(cmd, profile)
|
|
|
|
if not is_allowed:
|
|
return {
|
|
"decision": "block",
|
|
"reason": reason,
|
|
}
|
|
|
|
# Additional validation for sensitive commands
|
|
if cmd in VALIDATORS:
|
|
cmd_segment = get_command_for_validation(cmd, segments)
|
|
if not cmd_segment:
|
|
cmd_segment = command
|
|
|
|
validator = VALIDATORS[cmd]
|
|
allowed, reason = validator(cmd_segment)
|
|
if not allowed:
|
|
return {"decision": "block", "reason": reason}
|
|
|
|
return {}
|
|
|
|
|
|
def validate_command(
|
|
command: str,
|
|
project_dir: Path | None = None,
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Validate a command string (for testing/debugging).
|
|
|
|
Args:
|
|
command: Full command string to validate
|
|
project_dir: Optional project directory (uses cwd if not provided)
|
|
|
|
Returns:
|
|
(is_allowed, reason) tuple
|
|
"""
|
|
if project_dir is None:
|
|
project_dir = Path.cwd()
|
|
|
|
profile = get_security_profile(project_dir)
|
|
commands = extract_commands(command)
|
|
|
|
if not commands:
|
|
return False, "Could not parse command"
|
|
|
|
segments = split_command_segments(command)
|
|
|
|
for cmd in commands:
|
|
is_allowed_result, reason = is_command_allowed(cmd, profile)
|
|
if not is_allowed_result:
|
|
return False, reason
|
|
|
|
if cmd in VALIDATORS:
|
|
cmd_segment = get_command_for_validation(cmd, segments)
|
|
if not cmd_segment:
|
|
cmd_segment = command
|
|
|
|
validator = VALIDATORS[cmd]
|
|
allowed, reason = validator(cmd_segment)
|
|
if not allowed:
|
|
return False, reason
|
|
|
|
return True, ""
|