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:
Auto
2026-01-29 07:54:55 +02:00
parent 56f260cb79
commit 5ae7f4cffa
2 changed files with 138 additions and 10 deletions

View File

@@ -211,6 +211,46 @@ Defense-in-depth approach configured in `client.py`:
2. Filesystem restricted to project directory only
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
The agent's bash command access is controlled through a hierarchical configuration system:

102
client.py
View File

@@ -47,6 +47,23 @@ API_ENV_VARS = [
# Example: EXTRA_READ_PATHS=/Volumes/Data/dev,/Users/shared/libs
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:
"""
@@ -85,6 +102,79 @@ def get_playwright_browser() -> str:
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 = [
# Core feature operations
@@ -209,12 +299,10 @@ def create_client(
]
# Add extra read paths from environment variable (read-only access)
extra_read_paths = os.getenv(EXTRA_READ_PATHS_VAR, "")
if extra_read_paths:
for path in extra_read_paths.split(","):
path = path.strip()
if path:
# Add read-only permissions for each extra path
# Paths are validated, canonicalized, and checked against sensitive blocklist
extra_read_paths = get_extra_read_paths()
for path in extra_read_paths:
# Add read-only permissions for each validated path
permissions_list.append(f"Read({path}/**)")
permissions_list.append(f"Glob({path}/**)")
permissions_list.append(f"Grep({path}/**)")
@@ -246,7 +334,7 @@ def create_client(
print(" - Sandbox enabled (OS-level bash isolation)")
print(f" - Filesystem restricted to: {project_dir.resolve()}")
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)")
if yolo_mode:
print(" - MCP servers: features (database) - YOLO MODE (no Playwright)")