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>
This commit is contained in:
arcker
2026-01-06 13:27:27 +01:00
committed by GitHub
parent df57fbf8bc
commit 2f321fb2aa
7 changed files with 270 additions and 15 deletions
+6
View File
@@ -7,6 +7,7 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
import asyncio import asyncio
import logging import logging
import os
from pathlib import Path from pathlib import Path
from core.client import create_client from core.client import create_client
@@ -37,6 +38,7 @@ from prompt_generator import (
) )
from prompts import is_first_run from prompts import is_first_run
from recovery import RecoveryManager from recovery import RecoveryManager
from security.constants import PROJECT_DIR_ENV_VAR
from task_logger import ( from task_logger import (
LogPhase, LogPhase,
get_task_logger, get_task_logger,
@@ -90,6 +92,10 @@ async def run_autonomous_agent(
verbose: Whether to show detailed output verbose: Whether to show detailed output
source_spec_dir: Original spec directory in main project (for syncing from worktree) source_spec_dir: Original spec directory in main project (for syncing from worktree)
""" """
# Set environment variable for security hooks to find the correct project directory
# This is needed because os.getcwd() may return the wrong directory in worktree mode
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
# Initialize recovery manager (handles memory persistence) # Initialize recovery manager (handles memory persistence)
recovery_manager = RecoveryManager(spec_dir, project_dir) recovery_manager = RecoveryManager(spec_dir, project_dir)
+29
View File
@@ -13,6 +13,7 @@ import sys
from pathlib import Path from pathlib import Path
from merge import FileTimelineTracker from merge import FileTimelineTracker
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
from ui import ( from ui import (
Icons, Icons,
MenuOption, MenuOption,
@@ -267,6 +268,34 @@ def setup_workspace(
f"Environment files copied: {', '.join(copied_env_files)}", "success" f"Environment files copied: {', '.join(copied_env_files)}", "success"
) )
# Copy security configuration files if they exist
# Note: Unlike env files, security files always overwrite to ensure
# the worktree uses the same security rules as the main project.
# This prevents security bypasses through stale worktree configs.
security_files = [
ALLOWLIST_FILENAME,
PROFILE_FILENAME,
]
security_files_copied = []
for filename in security_files:
source_file = project_dir / filename
if source_file.is_file():
target_file = worktree_info.path / filename
try:
shutil.copy2(source_file, target_file)
security_files_copied.append(filename)
except (OSError, PermissionError) as e:
debug_warning(MODULE, f"Failed to copy {filename}: {e}")
print_status(
f"Warning: Could not copy {filename} to worktree", "warning"
)
if security_files_copied:
print_status(
f"Security config copied: {', '.join(security_files_copied)}", "success"
)
# Ensure .auto-claude/ is in the worktree's .gitignore # Ensure .auto-claude/ is in the worktree's .gitignore
# This is critical because the worktree inherits .gitignore from the base branch, # This is critical because the worktree inherits .gitignore from the base branch,
# which may not have .auto-claude/ if that change wasn't committed/pushed. # which may not have .auto-claude/ if that change wasn't committed/pushed.
+6
View File
@@ -6,6 +6,7 @@ Main QA loop that coordinates reviewer and fixer sessions until
approval or max iterations. approval or max iterations.
""" """
import os
import time as time_module import time as time_module
from pathlib import Path from pathlib import Path
@@ -22,6 +23,7 @@ from linear_updater import (
from phase_config import get_phase_model, get_phase_thinking_budget from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase from phase_event import ExecutionPhase, emit_phase
from progress import count_subtasks, is_build_complete from progress import count_subtasks, is_build_complete
from security.constants import PROJECT_DIR_ENV_VAR
from task_logger import ( from task_logger import (
LogPhase, LogPhase,
get_task_logger, get_task_logger,
@@ -83,6 +85,10 @@ async def run_qa_validation_loop(
Returns: Returns:
True if QA approved, False otherwise True if QA approved, False otherwise
""" """
# Set environment variable for security hooks to find the correct project directory
# This is needed because os.getcwd() may return the wrong directory in worktree mode
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
debug_section("qa_loop", "QA Validation Loop") debug_section("qa_loop", "QA Validation Loop")
debug( debug(
"qa_loop", "qa_loop",
+16
View File
@@ -0,0 +1,16 @@
"""
Security Constants
==================
Shared constants for the security module.
"""
# Environment variable name for the project directory
# Set by agents (coder.py, loop.py) at startup to ensure security hooks
# can find the correct project directory even in worktree mode.
PROJECT_DIR_ENV_VAR = "AUTO_CLAUDE_PROJECT_DIR"
# Security configuration filenames
# These are the files that control which commands are allowed to run.
ALLOWLIST_FILENAME = ".auto-claude-allowlist"
PROFILE_FILENAME = ".auto-claude-security.json"
+15 -2
View File
@@ -65,8 +65,21 @@ async def bash_security_hook(
if not command: if not command:
return {} return {}
# Get the working directory from input_data (SDK passes it there, not in context) # Get the working directory from context or use current directory
cwd = input_data.get("cwd") or os.getcwd() # 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 # Get or create security profile
# Note: In actual use, spec_dir would be passed through context # Note: In actual use, spec_dir would be passed through context
+44 -13
View File
@@ -9,11 +9,12 @@ Uses project_analyzer to create dynamic security profiles based on detected stac
from pathlib import Path from pathlib import Path
from project_analyzer import ( from project_analyzer import (
ProjectAnalyzer,
SecurityProfile, SecurityProfile,
get_or_create_profile, get_or_create_profile,
) )
from .constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
# ============================================================================= # =============================================================================
# GLOBAL STATE # GLOBAL STATE
# ============================================================================= # =============================================================================
@@ -23,18 +24,33 @@ _cached_profile: SecurityProfile | None = None
_cached_project_dir: Path | None = None _cached_project_dir: Path | None = None
_cached_spec_dir: Path | None = None # Track spec directory for cache key _cached_spec_dir: Path | None = None # Track spec directory for cache key
_cached_profile_mtime: float | None = None # Track file modification time _cached_profile_mtime: float | None = None # Track file modification time
_cached_allowlist_mtime: float | None = None # Track allowlist modification time
def _get_profile_path(project_dir: Path) -> Path: def _get_profile_path(project_dir: Path) -> Path:
"""Get the security profile file path for a project.""" """Get the security profile file path for a project."""
return project_dir / ProjectAnalyzer.PROFILE_FILENAME return project_dir / PROFILE_FILENAME
def _get_allowlist_path(project_dir: Path) -> Path:
"""Get the allowlist file path for a project."""
return project_dir / ALLOWLIST_FILENAME
def _get_profile_mtime(project_dir: Path) -> float | None: def _get_profile_mtime(project_dir: Path) -> float | None:
"""Get the modification time of the security profile file, or None if not exists.""" """Get the modification time of the security profile file, or None if not exists."""
profile_path = _get_profile_path(project_dir) profile_path = _get_profile_path(project_dir)
try: try:
return profile_path.stat().st_mtime if profile_path.exists() else None return profile_path.stat().st_mtime
except OSError:
return None
def _get_allowlist_mtime(project_dir: Path) -> float | None:
"""Get the modification time of the allowlist file, or None if not exists."""
allowlist_path = _get_allowlist_path(project_dir)
try:
return allowlist_path.stat().st_mtime
except OSError: except OSError:
return None return None
@@ -49,6 +65,7 @@ def get_security_profile(
- The project directory changes - The project directory changes
- The security profile file is created (was None, now exists) - The security profile file is created (was None, now exists)
- The security profile file is modified (mtime changed) - The security profile file is modified (mtime changed)
- The allowlist file is created, modified, or deleted
Args: Args:
project_dir: Project root directory project_dir: Project root directory
@@ -57,7 +74,11 @@ def get_security_profile(
Returns: Returns:
SecurityProfile for the project SecurityProfile for the project
""" """
global _cached_profile, _cached_project_dir, _cached_spec_dir, _cached_profile_mtime global _cached_profile
global _cached_project_dir
global _cached_spec_dir
global _cached_profile_mtime
global _cached_allowlist_mtime
project_dir = Path(project_dir).resolve() project_dir = Path(project_dir).resolve()
resolved_spec_dir = Path(spec_dir).resolve() if spec_dir else None resolved_spec_dir = Path(spec_dir).resolve() if spec_dir else None
@@ -68,30 +89,40 @@ def get_security_profile(
and _cached_project_dir == project_dir and _cached_project_dir == project_dir
and _cached_spec_dir == resolved_spec_dir and _cached_spec_dir == resolved_spec_dir
): ):
# Check if file has been created or modified since caching # Check if files have been created or modified since caching
current_mtime = _get_profile_mtime(project_dir) current_profile_mtime = _get_profile_mtime(project_dir)
# Cache is valid if: current_allowlist_mtime = _get_allowlist_mtime(project_dir)
# - Both are None (file never existed and still doesn't)
# - Both have same mtime (file unchanged) # Cache is valid if both mtimes are unchanged
if current_mtime == _cached_profile_mtime: if (
current_profile_mtime == _cached_profile_mtime
and current_allowlist_mtime == _cached_allowlist_mtime
):
return _cached_profile return _cached_profile
# File was created or modified - invalidate cache # File was created, modified, or deleted - invalidate cache
# (This happens when analyzer creates the file after agent starts) # (This happens when analyzer creates the file after agent starts,
# or when user adds/updates the allowlist)
# Analyze and cache # Analyze and cache
_cached_profile = get_or_create_profile(project_dir, spec_dir) _cached_profile = get_or_create_profile(project_dir, spec_dir)
_cached_project_dir = project_dir _cached_project_dir = project_dir
_cached_spec_dir = resolved_spec_dir _cached_spec_dir = resolved_spec_dir
_cached_profile_mtime = _get_profile_mtime(project_dir) _cached_profile_mtime = _get_profile_mtime(project_dir)
_cached_allowlist_mtime = _get_allowlist_mtime(project_dir)
return _cached_profile return _cached_profile
def reset_profile_cache() -> None: def reset_profile_cache() -> None:
"""Reset the cached profile (useful for testing or re-analysis).""" """Reset the cached profile (useful for testing or re-analysis)."""
global _cached_profile, _cached_project_dir, _cached_spec_dir, _cached_profile_mtime global _cached_profile
global _cached_project_dir
global _cached_spec_dir
global _cached_profile_mtime
global _cached_allowlist_mtime
_cached_profile = None _cached_profile = None
_cached_project_dir = None _cached_project_dir = None
_cached_spec_dir = None _cached_spec_dir = None
_cached_profile_mtime = None _cached_profile_mtime = None
_cached_allowlist_mtime = None
+154
View File
@@ -0,0 +1,154 @@
# Security Commands Configuration
Auto Claude uses a dynamic security system that controls which shell commands the AI agent can execute. This prevents potentially dangerous operations while allowing legitimate development commands.
## How It Works
```text
┌─────────────────────────────────────────────────────────────┐
│ Command Validation │
├─────────────────────────────────────────────────────────────┤
│ 1. Base Commands (always allowed) │
│ └── ls, cat, grep, git, echo, etc. │
│ │
│ 2. Auto-Detected Stack Commands │
│ └── Analyzer detects Cargo.toml → adds cargo, rustc │
│ └── Analyzer detects package.json → adds npm, node │
│ │
│ 3. Custom Allowlist (manual additions) │
│ └── .auto-claude-allowlist file │
└─────────────────────────────────────────────────────────────┘
```
## Automatic Detection
When you start a task, Auto Claude analyzes your project and automatically allows commands based on detected technologies:
| Detected File | Commands Added |
|---------------|----------------|
| `Cargo.toml` | `cargo`, `rustc`, `rustup`, `rustfmt`, `cargo-clippy`, etc. |
| `package.json` | `npm`, `node`, `npx` |
| `yarn.lock` | `yarn` |
| `pnpm-lock.yaml` | `pnpm` |
| `pyproject.toml` | `python`, `pip`, `poetry`, `uv` |
| `go.mod` | `go` |
| `*.csproj` / `*.sln` | `dotnet` |
| `pubspec.yaml` | `dart`, `flutter`, `pub` |
| `Dockerfile` | `docker` |
| `docker-compose.yml` | `docker-compose` |
| `Makefile` | `make` |
The full detection logic is in `apps/backend/project/stack_detector.py`.
### Generated Profile
The analyzer saves its results to `.auto-claude-security.json` in your project root:
```json
{
"base_commands": ["ls", "cat", "grep", "..."],
"stack_commands": ["cargo", "rustc", "rustup"],
"detected_stack": {
"languages": ["rust"],
"package_managers": ["cargo"],
"frameworks": [],
"databases": []
},
"project_hash": "abc123...",
"created_at": "2024-01-15T10:30:00"
}
```
This file is auto-generated. Don't edit it manually - it will be overwritten.
## Custom Allowlist
For commands that aren't auto-detected, create a `.auto-claude-allowlist` file in your project root:
```text
# .auto-claude-allowlist
# One command per line, no comments on same line
# Custom build tools
bazel
buck
# Project-specific scripts
./scripts/deploy.sh
# Additional tools
ansible
terraform
```
### When to Use the Allowlist
Use `.auto-claude-allowlist` when:
- Your project uses uncommon build tools (Bazel, Buck, Pants, etc.)
- You have custom scripts that need to be executable
- Auto-detection doesn't recognize your stack
- You're using bleeding-edge tools not yet in the detection system
### Format
- One command per line
- Lines starting with `#` are ignored
- Empty lines are ignored
- Use the base command name only (e.g., `cargo`, not `cargo build`)
## Troubleshooting
### "Command X is not allowed"
1. **Check if it should be auto-detected:**
- Does your project have the expected config file? (e.g., `Cargo.toml` for Rust)
- Run in project root, not a subdirectory
2. **Add to allowlist:**
```bash
echo "your-command" >> .auto-claude-allowlist
```
3. **Force re-analysis** (if detection seems wrong):
- Delete `.auto-claude-security.json`
- Restart the task
### Allowlist Changes Not Taking Effect
The security profile cache updates automatically when:
- `.auto-claude-allowlist` is modified (mtime changes)
- `.auto-claude-security.json` is modified
No restart required - changes apply on the next command.
### Worktree Mode
When using isolated worktrees, security files are automatically copied from your main project on each worktree setup.
**Important:** Unlike environment files (which are only copied if missing), security files **always overwrite** existing files in the worktree. This ensures the worktree uses the same security rules as the main project, preventing security bypasses through stale configurations.
This means:
- Changes to allowlist in the main project are reflected in new worktrees
- You cannot have different security rules per worktree (by design)
- If you need to test with different commands, modify the main project's allowlist
## Security Considerations
The allowlist system exists to prevent:
- Accidental `rm -rf /` or similar destructive commands
- Execution of unknown binaries
- Network operations with unrestricted tools
Only add commands you trust and understand.
## Adding Support for New Technologies
If you're using a technology that should be auto-detected, consider contributing:
1. Add detection logic to `apps/backend/project/stack_detector.py`
2. Add commands to `apps/backend/project/command_registry/languages.py`
3. Submit a PR!
See existing detectors for examples.