mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
security: harden EXTRA_READ_PATHS with validation and blocklist
Add security controls to the EXTRA_READ_PATHS feature (PR #126) to prevent path traversal attacks and accidental exposure of sensitive directories. Changes: - Add EXTRA_READ_PATHS_BLOCKLIST constant blocking credential directories (.ssh, .aws, .azure, .kube, .gnupg, .docker, .npmrc, .pypirc, .netrc) - Create get_extra_read_paths() function with comprehensive validation: - Path canonicalization via Path.resolve() to prevent .. traversal - Validates paths are absolute (rejects relative paths) - Validates paths exist and are directories - Blocks paths that are/contain sensitive directories - Blocks paths that would expose sensitive dirs (e.g., home dir) - Update create_client() to use validated getter function - Improve logging to show validated paths instead of raw input - Document security controls in CLAUDE.md under Security Model section Security considerations: - Addresses path traversal risk similar to CVE-2025-54794 - Prevents accidental exposure of SSH keys, cloud credentials, etc. - All validation happens before permissions are granted to the agent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
40
CLAUDE.md
40
CLAUDE.md
@@ -211,6 +211,46 @@ Defense-in-depth approach configured in `client.py`:
|
|||||||
2. Filesystem restricted to project directory only
|
2. Filesystem restricted to project directory only
|
||||||
3. Bash commands validated using hierarchical allowlist system
|
3. Bash commands validated using hierarchical allowlist system
|
||||||
|
|
||||||
|
#### Extra Read Paths (Cross-Project File Access)
|
||||||
|
|
||||||
|
The agent can optionally read files from directories outside the project folder via the `EXTRA_READ_PATHS` environment variable. This enables referencing documentation, shared libraries, or other projects.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single path
|
||||||
|
EXTRA_READ_PATHS=/Users/me/docs
|
||||||
|
|
||||||
|
# Multiple paths (comma-separated)
|
||||||
|
EXTRA_READ_PATHS=/Users/me/docs,/opt/shared-libs,/Volumes/Data/reference
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security Controls:**
|
||||||
|
|
||||||
|
All paths are validated before being granted read access:
|
||||||
|
- Must be absolute paths (not relative)
|
||||||
|
- Must exist and be directories
|
||||||
|
- Paths are canonicalized via `Path.resolve()` to prevent `..` traversal attacks
|
||||||
|
- Sensitive directories are blocked (see blocklist below)
|
||||||
|
- Only Read, Glob, and Grep operations are allowed (no Write/Edit)
|
||||||
|
|
||||||
|
**Blocked Sensitive Directories:**
|
||||||
|
|
||||||
|
The following directories (relative to home) are always blocked:
|
||||||
|
- `.ssh`, `.aws`, `.azure`, `.kube` - Cloud/SSH credentials
|
||||||
|
- `.gnupg`, `.gpg`, `.password-store` - Encryption keys
|
||||||
|
- `.docker`, `.config/gcloud` - Container/cloud configs
|
||||||
|
- `.npmrc`, `.pypirc`, `.netrc` - Package manager credentials
|
||||||
|
|
||||||
|
**Example Output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Created security settings at /path/to/project/.claude_settings.json
|
||||||
|
- Sandbox enabled (OS-level bash isolation)
|
||||||
|
- Filesystem restricted to: /path/to/project
|
||||||
|
- Extra read paths (validated): /Users/me/docs, /opt/shared-libs
|
||||||
|
```
|
||||||
|
|
||||||
#### Per-Project Allowed Commands
|
#### Per-Project Allowed Commands
|
||||||
|
|
||||||
The agent's bash command access is controlled through a hierarchical configuration system:
|
The agent's bash command access is controlled through a hierarchical configuration system:
|
||||||
|
|||||||
108
client.py
108
client.py
@@ -47,6 +47,23 @@ API_ENV_VARS = [
|
|||||||
# Example: EXTRA_READ_PATHS=/Volumes/Data/dev,/Users/shared/libs
|
# Example: EXTRA_READ_PATHS=/Volumes/Data/dev,/Users/shared/libs
|
||||||
EXTRA_READ_PATHS_VAR = "EXTRA_READ_PATHS"
|
EXTRA_READ_PATHS_VAR = "EXTRA_READ_PATHS"
|
||||||
|
|
||||||
|
# Sensitive directories that should never be allowed via EXTRA_READ_PATHS
|
||||||
|
# These contain credentials, keys, or system-critical files
|
||||||
|
EXTRA_READ_PATHS_BLOCKLIST = {
|
||||||
|
".ssh",
|
||||||
|
".aws",
|
||||||
|
".azure",
|
||||||
|
".kube",
|
||||||
|
".gnupg",
|
||||||
|
".gpg",
|
||||||
|
".password-store",
|
||||||
|
".docker",
|
||||||
|
".config/gcloud",
|
||||||
|
".npmrc",
|
||||||
|
".pypirc",
|
||||||
|
".netrc",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_playwright_headless() -> bool:
|
def get_playwright_headless() -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -85,6 +102,79 @@ def get_playwright_browser() -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def get_extra_read_paths() -> list[Path]:
|
||||||
|
"""
|
||||||
|
Get extra read-only paths from EXTRA_READ_PATHS environment variable.
|
||||||
|
|
||||||
|
Parses comma-separated absolute paths and validates each one:
|
||||||
|
- Must be an absolute path
|
||||||
|
- Must exist and be a directory
|
||||||
|
- Cannot be or contain sensitive directories (e.g., .ssh, .aws)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of validated, canonicalized Path objects.
|
||||||
|
"""
|
||||||
|
raw_value = os.getenv(EXTRA_READ_PATHS_VAR, "").strip()
|
||||||
|
if not raw_value:
|
||||||
|
return []
|
||||||
|
|
||||||
|
validated_paths: list[Path] = []
|
||||||
|
home_dir = Path.home()
|
||||||
|
|
||||||
|
for path_str in raw_value.split(","):
|
||||||
|
path_str = path_str.strip()
|
||||||
|
if not path_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse and canonicalize the path
|
||||||
|
try:
|
||||||
|
path = Path(path_str).resolve()
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
print(f" - Warning: Invalid EXTRA_READ_PATHS path '{path_str}': {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Must be absolute (resolve() makes it absolute, but check original input)
|
||||||
|
if not Path(path_str).is_absolute():
|
||||||
|
print(f" - Warning: EXTRA_READ_PATHS requires absolute paths, skipping: {path_str}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Must exist
|
||||||
|
if not path.exists():
|
||||||
|
print(f" - Warning: EXTRA_READ_PATHS path does not exist, skipping: {path_str}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Must be a directory
|
||||||
|
if not path.is_dir():
|
||||||
|
print(f" - Warning: EXTRA_READ_PATHS path is not a directory, skipping: {path_str}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check against sensitive directory blocklist
|
||||||
|
is_blocked = False
|
||||||
|
for sensitive in EXTRA_READ_PATHS_BLOCKLIST:
|
||||||
|
sensitive_path = (home_dir / sensitive).resolve()
|
||||||
|
try:
|
||||||
|
# Block if path IS the sensitive dir or is INSIDE it
|
||||||
|
if path == sensitive_path or path.is_relative_to(sensitive_path):
|
||||||
|
print(f" - Warning: EXTRA_READ_PATHS blocked sensitive path: {path_str}")
|
||||||
|
is_blocked = True
|
||||||
|
break
|
||||||
|
# Also block if sensitive dir is INSIDE the requested path
|
||||||
|
if sensitive_path.is_relative_to(path):
|
||||||
|
print(f" - Warning: EXTRA_READ_PATHS path contains sensitive directory ({sensitive}): {path_str}")
|
||||||
|
is_blocked = True
|
||||||
|
break
|
||||||
|
except (OSError, ValueError):
|
||||||
|
# is_relative_to can raise on some edge cases
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_blocked:
|
||||||
|
continue
|
||||||
|
|
||||||
|
validated_paths.append(path)
|
||||||
|
|
||||||
|
return validated_paths
|
||||||
|
|
||||||
|
|
||||||
# Feature MCP tools for feature/test management
|
# Feature MCP tools for feature/test management
|
||||||
FEATURE_MCP_TOOLS = [
|
FEATURE_MCP_TOOLS = [
|
||||||
# Core feature operations
|
# Core feature operations
|
||||||
@@ -209,15 +299,13 @@ def create_client(
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Add extra read paths from environment variable (read-only access)
|
# Add extra read paths from environment variable (read-only access)
|
||||||
extra_read_paths = os.getenv(EXTRA_READ_PATHS_VAR, "")
|
# Paths are validated, canonicalized, and checked against sensitive blocklist
|
||||||
if extra_read_paths:
|
extra_read_paths = get_extra_read_paths()
|
||||||
for path in extra_read_paths.split(","):
|
for path in extra_read_paths:
|
||||||
path = path.strip()
|
# Add read-only permissions for each validated path
|
||||||
if path:
|
permissions_list.append(f"Read({path}/**)")
|
||||||
# Add read-only permissions for each extra path
|
permissions_list.append(f"Glob({path}/**)")
|
||||||
permissions_list.append(f"Read({path}/**)")
|
permissions_list.append(f"Grep({path}/**)")
|
||||||
permissions_list.append(f"Glob({path}/**)")
|
|
||||||
permissions_list.append(f"Grep({path}/**)")
|
|
||||||
|
|
||||||
if not yolo_mode:
|
if not yolo_mode:
|
||||||
# Allow Playwright MCP tools for browser automation (standard mode only)
|
# Allow Playwright MCP tools for browser automation (standard mode only)
|
||||||
@@ -246,7 +334,7 @@ def create_client(
|
|||||||
print(" - Sandbox enabled (OS-level bash isolation)")
|
print(" - Sandbox enabled (OS-level bash isolation)")
|
||||||
print(f" - Filesystem restricted to: {project_dir.resolve()}")
|
print(f" - Filesystem restricted to: {project_dir.resolve()}")
|
||||||
if extra_read_paths:
|
if extra_read_paths:
|
||||||
print(f" - Extra read paths: {extra_read_paths}")
|
print(f" - Extra read paths (validated): {', '.join(str(p) for p in extra_read_paths)}")
|
||||||
print(" - Bash commands restricted to allowlist (see security.py)")
|
print(" - Bash commands restricted to allowlist (see security.py)")
|
||||||
if yolo_mode:
|
if yolo_mode:
|
||||||
print(" - MCP servers: features (database) - YOLO MODE (no Playwright)")
|
print(" - MCP servers: features (database) - YOLO MODE (no Playwright)")
|
||||||
|
|||||||
Reference in New Issue
Block a user