diff --git a/CLAUDE.md b/CLAUDE.md index e01cc93..30b5f30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/client.py b/client.py index f743620..423845d 100644 --- a/client.py +++ b/client.py @@ -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,15 +299,13 @@ 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 - permissions_list.append(f"Read({path}/**)") - permissions_list.append(f"Glob({path}/**)") - permissions_list.append(f"Grep({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}/**)") if not yolo_mode: # 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(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)")