Skip to content
7 changes: 5 additions & 2 deletions apps/backend/cli/batch_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
"""

import json
import os
from pathlib import Path

from core.config import get_worktree_base_path
from ui import highlight, print_status


Expand Down Expand Up @@ -184,7 +186,8 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
True if successful
"""
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
worktrees_dir = Path(project_dir) / ".worktrees"
worktree_base_path = get_worktree_base_path(Path(project_dir))
worktrees_dir = Path(project_dir) / worktree_base_path

if not specs_dir.exists():
print_status("No specs directory found", "info")
Expand All @@ -209,7 +212,7 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
print(f" - {spec_name}")
wt_path = worktrees_dir / spec_name
if wt_path.exists():
print(f" └─ .worktrees/{spec_name}/")
print(f" └─ {worktree_base_path}/{spec_name}/")
print()
print("Run with --no-dry-run to actually delete")

Expand Down
8 changes: 8 additions & 0 deletions apps/backend/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))

from dotenv import load_dotenv


from .batch_commands import (
handle_batch_cleanup_command,
Expand Down Expand Up @@ -276,6 +278,12 @@ def main() -> None:
project_dir = get_project_dir(args.project_dir)
debug("run.py", f"Using project directory: {project_dir}")

# Load project-specific .env file (overrides backend .env)
project_env = project_dir / ".auto-claude" / ".env"
if project_env.exists():
load_dotenv(project_env, override=True)
debug("run.py", f"Loaded project .env from: {project_env}")

# Get model from CLI arg or env var (None if not explicitly set)
# This allows get_phase_model() to fall back to task_metadata.json
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
sys.path.insert(0, str(_PARENT_DIR))

from core.auth import get_auth_token, get_auth_token_source
from core.config import get_worktree_base_path
from dotenv import load_dotenv
from graphiti_config import get_graphiti_status
from linear_integration import LinearManager
Expand Down Expand Up @@ -82,7 +83,8 @@ def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
return spec_folder

# Check worktree specs (for merge-preview, merge, review, discard operations)
worktree_base = project_dir / ".worktrees"
worktree_base_path = get_worktree_base_path(project_dir)
worktree_base = project_dir / worktree_base_path
if worktree_base.exists():
# Try exact match in worktree
worktree_spec = (
Expand Down
118 changes: 118 additions & 0 deletions apps/backend/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
Core configuration for Auto Claude.

This module provides centralized configuration management for Auto Claude,
including worktree path resolution and validation. It ensures consistent
configuration access across the entire backend codebase.

Constants:
WORKTREE_BASE_PATH_VAR (str): Environment variable name for custom worktree base path.
DEFAULT_WORKTREE_PATH (str): Default worktree directory name relative to project root.

Example:
>>> from core.config import get_worktree_base_path
>>> from pathlib import Path
>>>
>>> # Get worktree path with validation
>>> project_dir = Path("/path/to/project")
>>> worktree_path = get_worktree_base_path(project_dir)
>>> full_path = project_dir / worktree_path
"""

import os
from pathlib import Path

# Environment variable names
WORKTREE_BASE_PATH_VAR = "WORKTREE_BASE_PATH"
"""str: Environment variable name for configuring custom worktree base path.

Users can set this environment variable in their project's .env file to specify
a custom location for worktree directories, supporting both relative and absolute paths.
"""

# Default values
DEFAULT_WORKTREE_PATH = ".worktrees"
"""str: Default worktree directory name.

This is the fallback value used when WORKTREE_BASE_PATH is not set or when
validation fails (e.g., path points to .auto-claude/ or .git/ directories).
"""


def get_worktree_base_path(project_dir: Path | None = None) -> str:
"""
Get the worktree base path from environment variables with security validation.

This function reads the WORKTREE_BASE_PATH environment variable and validates
it to prevent security issues. It supports both relative and absolute paths,
enabling users to place worktrees on external drives or temporary directories.

Supported path types:
- Relative paths: 'worktrees', '.cache/worktrees', '../shared-worktrees'
- Absolute paths: '/tmp/worktrees', '/Volumes/FastSSD/worktrees', 'C:\\worktrees'

Security validations:
- Prevents paths inside .auto-claude/ directory (avoids data loss during cleanup)
- Prevents paths inside .git/ directory (avoids repository corruption)
- Falls back to DEFAULT_WORKTREE_PATH if validation fails

Args:
project_dir (Path | None): Project root directory for full validation.
If None, only basic pattern validation is performed (checks for
.auto-claude and .git in path string). If provided, performs
full path resolution and validation.

Returns:
str: The validated worktree base path string. Returns DEFAULT_WORKTREE_PATH
('.worktrees') if the configured path is invalid or dangerous.

Examples:
>>> # Basic usage without validation
>>> path = get_worktree_base_path()
>>> print(path)
'.worktrees'

>>> # Full validation with project directory
>>> from pathlib import Path
>>> project = Path("/home/user/my-project")
>>> path = get_worktree_base_path(project)
>>> full_path = project / path

>>> # With WORKTREE_BASE_PATH="/tmp/worktrees" in environment
>>> os.environ['WORKTREE_BASE_PATH'] = '/tmp/worktrees'
>>> path = get_worktree_base_path(project)
>>> print(path)
'/tmp/worktrees'

Note:
The function intentionally allows absolute paths outside the project
directory to support use cases like external drives or shared build
directories. This is a design decision, not a security flaw.
"""
worktree_base_path = os.getenv(WORKTREE_BASE_PATH_VAR, DEFAULT_WORKTREE_PATH)

# If no project_dir provided, return as-is (basic validation only)
if not project_dir:
# Check for .auto-claude or .git as path components, not substrings
parts = set(Path(worktree_base_path).parts)
if ".auto-claude" in parts or ".git" in parts:
return DEFAULT_WORKTREE_PATH
return worktree_base_path

# Resolve the absolute path
if Path(worktree_base_path).is_absolute():
resolved = Path(worktree_base_path).resolve()
else:
resolved = (project_dir / worktree_base_path).resolve()

# Prevent paths inside .auto-claude/ or .git/
auto_claude_dir = (project_dir / ".auto-claude").resolve()
git_dir = (project_dir / ".git").resolve()

resolved_str = str(resolved)
if resolved_str.startswith(str(auto_claude_dir)) or resolved_str.startswith(
str(git_dir)
):
return DEFAULT_WORKTREE_PATH
Comment on lines +112 to +116
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix security check to avoid false positives for similar directory names.

The startswith check on string paths creates false positives. For example:

  • A path resolving to /project/.auto-claude-backup would incorrectly match /project/.auto-claude
  • A path resolving to /project/.git-hooks would incorrectly match /project/.git

Use Path.is_relative_to() (available since Python 3.9, compatible with the Python 3.12+ requirement) to check true parent-child relationships.

🔎 Use Path.is_relative_to() for accurate directory containment checks
     # Prevent paths inside .auto-claude/ or .git/
     auto_claude_dir = (project_dir / ".auto-claude").resolve()
     git_dir = (project_dir / ".git").resolve()

-    resolved_str = str(resolved)
-    if resolved_str.startswith(str(auto_claude_dir)) or resolved_str.startswith(
-        str(git_dir)
-    ):
+    if resolved.is_relative_to(auto_claude_dir) or resolved.is_relative_to(git_dir):
         return DEFAULT_WORKTREE_PATH

     return worktree_base_path
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
resolved_str = str(resolved)
if resolved_str.startswith(str(auto_claude_dir)) or resolved_str.startswith(
str(git_dir)
):
return DEFAULT_WORKTREE_PATH
resolved_str = str(resolved)
if resolved.is_relative_to(auto_claude_dir) or resolved.is_relative_to(git_dir):
return DEFAULT_WORKTREE_PATH
return worktree_base_path
🤖 Prompt for AI Agents
In apps/backend/core/config.py around lines 112 to 116, the security check uses
string startswith() which produces false positives for similarly named
directories; replace the string comparisons with proper Path containment checks:
convert resolved, auto_claude_dir, and git_dir to pathlib.Path and use
Path.is_relative_to() (or check equality or is_relative_to) to determine true
parent/child relationships, and return DEFAULT_WORKTREE_PATH only when resolved
is equal to or is_relative_to those directories.


return worktree_base_path
8 changes: 6 additions & 2 deletions apps/backend/core/workspace/git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
"""

import json
import os
import subprocess
from pathlib import Path

from core.config import get_worktree_base_path

# Constants for merge limits
MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this
MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations
Expand Down Expand Up @@ -221,8 +224,9 @@ def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Path | Non
Returns:
Path to the worktree if it exists for this spec, None otherwise
"""
# Per-spec worktree path: .worktrees/{spec-name}/
worktree_path = project_dir / ".worktrees" / spec_name
# Per-spec worktree path: .worktrees/{spec-name}/ (or custom WORKTREE_BASE_PATH)
worktree_base_path = get_worktree_base_path(project_dir)
worktree_path = project_dir / worktree_base_path / spec_name
if worktree_path.exists():
return worktree_path
return None
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/core/workspace/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
Data classes and enums for workspace management.
"""

import os
from dataclasses import dataclass
from enum import Enum
from pathlib import Path

from core.config import get_worktree_base_path


class WorkspaceMode(Enum):
"""How auto-claude should work."""
Expand Down Expand Up @@ -249,7 +252,8 @@ def get_next_spec_number(self) -> int:
max_number = max(max_number, self._scan_specs_dir(main_specs_dir))

# 2. Scan all worktree specs
worktrees_dir = self.project_dir / ".worktrees"
worktree_base_path = get_worktree_base_path(self.project_dir)
worktrees_dir = self.project_dir / worktree_base_path
if worktrees_dir.exists():
for worktree in worktrees_dir.iterdir():
if worktree.is_dir():
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/core/worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from dataclasses import dataclass
from pathlib import Path

from core.config import get_worktree_base_path


class WorktreeError(Exception):
"""Error during worktree operations."""
Expand Down Expand Up @@ -55,7 +57,11 @@ class WorktreeManager:
def __init__(self, project_dir: Path, base_branch: str | None = None):
self.project_dir = project_dir
self.base_branch = base_branch or self._detect_base_branch()
self.worktrees_dir = project_dir / ".worktrees"

# Use custom worktree path from environment variable with validation
worktree_base_path = get_worktree_base_path(project_dir)
self.worktrees_dir = project_dir / worktree_base_path

self._merge_lock = asyncio.Lock()

def _detect_base_branch(self) -> str:
Expand Down
78 changes: 0 additions & 78 deletions apps/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions apps/frontend/src/main/ipc-handlers/env-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ export function registerEnvHandlers(
if (config.defaultBranch !== undefined) {
existingVars['DEFAULT_BRANCH'] = config.defaultBranch;
}
if (config.worktreePath !== undefined) {
existingVars['WORKTREE_BASE_PATH'] = config.worktreePath;
}
if (config.graphitiEnabled !== undefined) {
existingVars['GRAPHITI_ENABLED'] = config.graphitiEnabled ? 'true' : 'false';
}
Expand Down Expand Up @@ -228,6 +231,11 @@ ${envLine(existingVars, GITLAB_ENV_KEYS.AUTO_SYNC, 'false')}
# If not set, Auto Claude will auto-detect main/master, or fall back to current branch
${existingVars['DEFAULT_BRANCH'] ? `DEFAULT_BRANCH=${existingVars['DEFAULT_BRANCH']}` : '# DEFAULT_BRANCH=main'}

# Worktree base path (OPTIONAL)
# Supports relative paths (e.g., worktrees) or absolute paths (e.g., /tmp/worktrees)
# If not set, defaults to .worktrees in your project root
${existingVars['WORKTREE_BASE_PATH'] ? `WORKTREE_BASE_PATH=${existingVars['WORKTREE_BASE_PATH']}` : '# WORKTREE_BASE_PATH=.worktrees'}

# =============================================================================
# UI SETTINGS (OPTIONAL)
# =============================================================================
Expand Down Expand Up @@ -411,6 +419,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
if (vars['DEFAULT_BRANCH']) {
config.defaultBranch = vars['DEFAULT_BRANCH'];
}
if (vars['WORKTREE_BASE_PATH']) {
config.worktreePath = vars['WORKTREE_BASE_PATH'];
}

if (vars['GRAPHITI_ENABLED']?.toLowerCase() === 'true') {
config.graphitiEnabled = true;
Expand Down
Loading