Files
Aperant/apps/backend/core/workspace/setup.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

450 lines
14 KiB
Python

#!/usr/bin/env python3
"""
Workspace Setup
===============
Functions for setting up and initializing workspaces.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
from merge import FileTimelineTracker
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
from ui import (
Icons,
MenuOption,
box,
icon,
muted,
print_status,
select_menu,
success,
)
from worktree import WorktreeManager
from .git_utils import has_uncommitted_changes
from .models import WorkspaceMode
# Import debug utilities
try:
from debug import debug, debug_warning
except ImportError:
def debug(*args, **kwargs):
pass
def debug_warning(*args, **kwargs):
pass
# Track if we've already tried to install the git hook this session
_git_hook_check_done = False
MODULE = "workspace.setup"
def choose_workspace(
project_dir: Path,
spec_name: str,
force_isolated: bool = False,
force_direct: bool = False,
auto_continue: bool = False,
) -> WorkspaceMode:
"""
Let user choose where auto-claude should work.
Uses simple, non-technical language. Safe defaults.
Args:
project_dir: The project directory
spec_name: Name of the spec being built
force_isolated: Skip prompts and use isolated mode
force_direct: Skip prompts and use direct mode
auto_continue: Non-interactive mode (for UI integration) - skip all prompts
Returns:
WorkspaceMode indicating where to work
"""
# Handle forced modes
if force_isolated:
return WorkspaceMode.ISOLATED
if force_direct:
return WorkspaceMode.DIRECT
# Non-interactive mode: default to isolated for safety
if auto_continue:
print("Auto-continue: Using isolated workspace for safety.")
return WorkspaceMode.ISOLATED
# Check for unsaved work
has_unsaved = has_uncommitted_changes(project_dir)
if has_unsaved:
# Unsaved work detected - use isolated mode for safety
content = [
success(f"{icon(Icons.SHIELD)} YOUR WORK IS PROTECTED"),
"",
"You have unsaved work in your project.",
"",
"To keep your work safe, the AI will build in a",
"separate workspace. Your current files won't be",
"touched until you're ready.",
]
print()
print(box(content, width=60, style="heavy"))
print()
try:
input("Press Enter to continue...")
except KeyboardInterrupt:
print()
print_status("Cancelled.", "info")
sys.exit(0)
return WorkspaceMode.ISOLATED
# Clean working directory - give choice with enhanced menu
options = [
MenuOption(
key="isolated",
label="Separate workspace (Recommended)",
icon=Icons.SHIELD,
description="Your current files stay untouched. Easy to review and undo.",
),
MenuOption(
key="direct",
label="Right here in your project",
icon=Icons.LIGHTNING,
description="Changes happen directly. Best if you're not working on anything else.",
),
]
choice = select_menu(
title="Where should the AI build your feature?",
options=options,
allow_quit=True,
)
if choice is None:
print()
print_status("Cancelled.", "info")
sys.exit(0)
if choice == "direct":
print()
print_status("Working directly in your project.", "info")
return WorkspaceMode.DIRECT
else:
print()
print_status("Using a separate workspace for safety.", "success")
return WorkspaceMode.ISOLATED
def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[str]:
"""
Copy .env files from project root to worktree (without overwriting).
This ensures the worktree has access to environment variables needed
to run the project (e.g., API keys, database URLs).
Args:
project_dir: The main project directory
worktree_path: Path to the worktree
Returns:
List of copied file names
"""
copied = []
# Common .env file patterns - copy if they exist
env_patterns = [
".env",
".env.local",
".env.development",
".env.development.local",
".env.test",
".env.test.local",
]
for pattern in env_patterns:
env_file = project_dir / pattern
if env_file.is_file():
target = worktree_path / pattern
if not target.exists():
shutil.copy2(env_file, target)
copied.append(pattern)
debug(MODULE, f"Copied {pattern} to worktree")
return copied
def copy_spec_to_worktree(
source_spec_dir: Path,
worktree_path: Path,
spec_name: str,
) -> Path:
"""
Copy spec files into the worktree so the AI can access them.
The AI's filesystem is restricted to the worktree, so spec files
must be copied inside for access.
Args:
source_spec_dir: Original spec directory (may be outside worktree)
worktree_path: Path to the worktree
spec_name: Name of the spec folder
Returns:
Path to the spec directory inside the worktree
"""
# Determine target location inside worktree
# Use .auto-claude/specs/{spec_name}/ as the standard location
# Note: auto-claude/ is source code, .auto-claude/ is the installed instance
target_spec_dir = worktree_path / ".auto-claude" / "specs" / spec_name
# Create parent directories if needed
target_spec_dir.parent.mkdir(parents=True, exist_ok=True)
# Copy spec files (overwrite if exists to get latest)
if target_spec_dir.exists():
shutil.rmtree(target_spec_dir)
shutil.copytree(source_spec_dir, target_spec_dir)
return target_spec_dir
def setup_workspace(
project_dir: Path,
spec_name: str,
mode: WorkspaceMode,
source_spec_dir: Path | None = None,
base_branch: str | None = None,
) -> tuple[Path, WorktreeManager | None, Path | None]:
"""
Set up the workspace based on user's choice.
Uses per-spec worktrees - each spec gets its own isolated worktree.
Args:
project_dir: The project directory
spec_name: Name of the spec being built (e.g., "001-feature-name")
mode: The workspace mode to use
source_spec_dir: Optional source spec directory to copy to worktree
base_branch: Base branch for worktree creation (default: current branch)
Returns:
Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None)
When using isolated mode with source_spec_dir:
- working_directory: Path to the worktree
- worktree_manager: Manager for the worktree
- localized_spec_dir: Path to spec files INSIDE the worktree (accessible to AI)
"""
if mode == WorkspaceMode.DIRECT:
# Work directly in project - spec_dir stays as-is
return project_dir, None, source_spec_dir
# Create isolated workspace using per-spec worktree
print()
print_status("Setting up separate workspace...", "progress")
# Ensure timeline tracking hook is installed (once per session)
ensure_timeline_hook_installed(project_dir)
manager = WorktreeManager(project_dir, base_branch=base_branch)
manager.setup()
# Get or create worktree for THIS SPECIFIC SPEC
worktree_info = manager.get_or_create_worktree(spec_name)
# Copy .env files to worktree so user can run the project
copied_env_files = copy_env_files_to_worktree(project_dir, worktree_info.path)
if copied_env_files:
print_status(
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
# 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.
# Without this, spec files would be committed to the worktree's branch.
from init import ensure_gitignore_entry
if ensure_gitignore_entry(worktree_info.path, ".auto-claude/"):
debug(MODULE, "Added .auto-claude/ to worktree's .gitignore")
# Copy spec files to worktree if provided
localized_spec_dir = None
if source_spec_dir and source_spec_dir.exists():
localized_spec_dir = copy_spec_to_worktree(
source_spec_dir, worktree_info.path, spec_name
)
print_status("Spec files copied to workspace", "success")
print_status(f"Workspace ready: {worktree_info.path.name}", "success")
print()
# Initialize FileTimelineTracker for this task
initialize_timeline_tracking(
project_dir=project_dir,
spec_name=spec_name,
worktree_path=worktree_info.path,
source_spec_dir=localized_spec_dir or source_spec_dir,
)
return worktree_info.path, manager, localized_spec_dir
def ensure_timeline_hook_installed(project_dir: Path) -> None:
"""
Ensure the FileTimelineTracker git post-commit hook is installed.
This enables tracking human commits to main branch for drift detection.
Called once per session during first workspace setup.
"""
global _git_hook_check_done
if _git_hook_check_done:
return
_git_hook_check_done = True
try:
git_dir = project_dir / ".git"
if not git_dir.exists():
return # Not a git repo
# Handle worktrees (where .git is a file, not directory)
if git_dir.is_file():
content = git_dir.read_text().strip()
if content.startswith("gitdir:"):
git_dir = Path(content.split(":", 1)[1].strip())
else:
return
hook_path = git_dir / "hooks" / "post-commit"
# Check if hook already installed
if hook_path.exists():
if "FileTimelineTracker" in hook_path.read_text():
debug(MODULE, "FileTimelineTracker hook already installed")
return
# Auto-install the hook (silent, non-intrusive)
from merge.install_hook import install_hook
install_hook(project_dir)
debug(MODULE, "Auto-installed FileTimelineTracker git hook")
except Exception as e:
# Non-fatal - hook installation is optional
debug_warning(MODULE, f"Could not auto-install timeline hook: {e}")
def initialize_timeline_tracking(
project_dir: Path,
spec_name: str,
worktree_path: Path,
source_spec_dir: Path | None = None,
) -> None:
"""
Initialize FileTimelineTracker for a new task.
This registers the task's branch point and the files it intends to modify,
enabling intent-aware merge conflict resolution later.
"""
try:
tracker = FileTimelineTracker(project_dir)
# Get task intent from implementation plan
task_intent = ""
task_title = spec_name
files_to_modify = []
if source_spec_dir:
plan_path = source_spec_dir / "implementation_plan.json"
if plan_path.exists():
with open(plan_path) as f:
plan = json.load(f)
task_title = plan.get("title", spec_name)
task_intent = plan.get("description", "")
# Extract files from phases/subtasks
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
files_to_modify.extend(subtask.get("files", []))
# Get the current branch point commit
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
branch_point = result.stdout.strip() if result.returncode == 0 else None
if files_to_modify and branch_point:
# Register the task with known files
tracker.on_task_start(
task_id=spec_name,
files_to_modify=list(set(files_to_modify)), # Dedupe
branch_point_commit=branch_point,
task_intent=task_intent,
task_title=task_title,
)
debug(
MODULE,
f"Timeline tracking initialized for {spec_name}",
files_tracked=len(files_to_modify),
branch_point=branch_point[:8] if branch_point else None,
)
else:
# Initialize retroactively from worktree if no plan
tracker.initialize_from_worktree(
task_id=spec_name,
worktree_path=worktree_path,
task_intent=task_intent,
task_title=task_title,
)
except Exception as e:
# Non-fatal - timeline tracking is supplementary
debug_warning(MODULE, f"Could not initialize timeline tracking: {e}")
print(muted(f" Note: Timeline tracking could not be initialized: {e}"))
# Export private functions for backward compatibility
_ensure_timeline_hook_installed = ensure_timeline_hook_installed
_initialize_timeline_tracking = initialize_timeline_tracking