Files
Aperant/apps/backend/security/hooks.py
T
arcker 2f321fb2aa Fix: Security allowlist not working in worktree mode (#646)
* 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>
2026-01-06 13:27:27 +01:00

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, ""