From 750ea8d1886844470f991d16fbf32f81dee9ae5e Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:11:51 +0100 Subject: [PATCH] fix(security): inherit security profiles in worktrees and validate shell -c commands (#971) * fix(security): inherit security profiles in worktrees and validate shell -c commands - Add inherited_from field to SecurityProfile to mark profiles copied from parent projects - Skip hash-based re-analysis for inherited profiles (fixes worktrees losing npm/npx etc.) - Add shell_validators.py to validate commands inside bash/sh/zsh -c strings - Register shell validators to close security bypass via bash -c "arbitrary_command" - Add 13 new tests for inherited profiles and shell -c validation Fixes worktree security config not being inherited, which caused agents to be blocked from running npm/npx commands in isolated workspaces. * docs: update README download links to v2.7.3 (#976) - Update all stable download links from 2.7.2 to 2.7.3 - Add Flatpak download link (new in 2.7.3) * fix(security): close shell -c bypass vectors and validate inherited profiles - Fix combined shell flags bypass (-xc, -ec, -ic) in _extract_c_argument() Shell allows combining flags like `bash -xc 'cmd'` which bypassed -c detection - Add recursive validation for nested shell invocations Prevents bypass via `bash -c "bash -c 'evil_cmd'"` - Validate inherited_from path in should_reanalyze() with defense-in-depth - Must exist and be a directory - Must be an ancestor of current project - Must contain valid security profile - Add comprehensive test coverage for all security fixes Co-Authored-By: Claude Opus 4.5 * style: fix import ordering in test_security.py Co-Authored-By: Claude Opus 4.5 * style: format shell_validators.py Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- apps/backend/core/workspace/setup.py | 17 + apps/backend/project/analyzer.py | 37 +- apps/backend/project/command_registry/base.py | 4 + apps/backend/project/models.py | 10 +- apps/backend/security/__init__.py | 8 + apps/backend/security/shell_validators.py | 142 ++++ apps/backend/security/validator.py | 11 + apps/backend/security/validator_registry.py | 9 + tests/test_security.py | 769 +++++++++++++++++- 9 files changed, 987 insertions(+), 20 deletions(-) create mode 100644 apps/backend/security/shell_validators.py diff --git a/apps/backend/core/workspace/setup.py b/apps/backend/core/workspace/setup.py index 06269e7c..adf31d31 100644 --- a/apps/backend/core/workspace/setup.py +++ b/apps/backend/core/workspace/setup.py @@ -296,6 +296,23 @@ def setup_workspace( f"Security config copied: {', '.join(security_files_copied)}", "success" ) + # Mark the security profile as inherited from parent project + # This prevents hash-based re-analysis which would produce a broken profile + # (worktrees lack node_modules and other build artifacts needed for detection) + if PROFILE_FILENAME in security_files_copied: + profile_path = worktree_info.path / PROFILE_FILENAME + try: + with open(profile_path) as f: + profile_data = json.load(f) + profile_data["inherited_from"] = str(project_dir.resolve()) + with open(profile_path, "w") as f: + json.dump(profile_data, f, indent=2) + debug( + MODULE, f"Marked security profile as inherited from {project_dir}" + ) + except (OSError, json.JSONDecodeError) as e: + debug_warning(MODULE, f"Failed to mark profile as inherited: {e}") + # 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. diff --git a/apps/backend/project/analyzer.py b/apps/backend/project/analyzer.py index 67570add..6054f8f5 100644 --- a/apps/backend/project/analyzer.py +++ b/apps/backend/project/analyzer.py @@ -188,10 +188,38 @@ class ProjectAnalyzer: return hasher.hexdigest() def should_reanalyze(self, profile: SecurityProfile) -> bool: - """Check if project has changed since last analysis.""" + """Check if project has changed since last analysis. + + Never re-analyzes inherited profiles (from worktrees) since they + came from a validated parent project with full context (e.g., node_modules). + """ + # Never re-analyze inherited profiles - they came from a validated parent + # But validate that inherited_from points to a legitimate parent + if profile.inherited_from: + parent = Path(profile.inherited_from) + # Validate the inherited_from path: + # 1. Must exist and be a directory + # 2. Current project must be a descendant of the parent + # 3. Parent must contain a valid security profile + if ( + parent.exists() + and parent.is_dir() + and self._is_descendant_of(self.project_dir, parent) + and (parent / self.PROFILE_FILENAME).exists() + ): + return False + # If validation fails, treat as non-inherited and check hash current_hash = self.compute_project_hash() return current_hash != profile.project_hash + def _is_descendant_of(self, child: Path, parent: Path) -> bool: + """Check if child path is a descendant of parent path.""" + try: + child.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + def analyze(self, force: bool = False) -> SecurityProfile: """ Perform full project analysis. @@ -205,7 +233,12 @@ class ProjectAnalyzer: # Check for existing profile existing = self.load_profile() if existing and not force and not self.should_reanalyze(existing): - print(f"Using cached security profile (hash: {existing.project_hash[:8]})") + if existing.inherited_from: + print("Using inherited security profile from parent project") + else: + print( + f"Using cached security profile (hash: {existing.project_hash[:8]})" + ) return existing print("Analyzing project structure for security profile...") diff --git a/apps/backend/project/command_registry/base.py b/apps/backend/project/command_registry/base.py index 9c070632..04c5f763 100644 --- a/apps/backend/project/command_registry/base.py +++ b/apps/backend/project/command_registry/base.py @@ -158,6 +158,10 @@ VALIDATED_COMMANDS: dict[str, str] = { "pkill": "validate_pkill", "kill": "validate_kill", "killall": "validate_killall", + # Shell interpreters - validate commands inside -c + "bash": "validate_shell_c", + "sh": "validate_shell_c", + "zsh": "validate_shell_c", } diff --git a/apps/backend/project/models.py b/apps/backend/project/models.py index 9f5514f3..b36279b4 100644 --- a/apps/backend/project/models.py +++ b/apps/backend/project/models.py @@ -52,6 +52,9 @@ class SecurityProfile: project_dir: str = "" created_at: str = "" project_hash: str = "" + inherited_from: str = ( + "" # Source project path if inherited from parent (e.g., worktree) + ) def get_all_allowed_commands(self) -> set[str]: """Get the complete set of allowed commands.""" @@ -64,7 +67,7 @@ class SecurityProfile: def to_dict(self) -> dict: """Convert to JSON-serializable dict.""" - return { + result = { "base_commands": sorted(self.base_commands), "stack_commands": sorted(self.stack_commands), "script_commands": sorted(self.script_commands), @@ -75,6 +78,10 @@ class SecurityProfile: "created_at": self.created_at, "project_hash": self.project_hash, } + # Only include inherited_from if set (to keep backward compatibility) + if self.inherited_from: + result["inherited_from"] = self.inherited_from + return result @classmethod def from_dict(cls, data: dict) -> "SecurityProfile": @@ -87,6 +94,7 @@ class SecurityProfile: project_dir=data.get("project_dir", ""), created_at=data.get("created_at", ""), project_hash=data.get("project_hash", ""), + inherited_from=data.get("inherited_from", ""), ) if "detected_stack" in data: diff --git a/apps/backend/security/__init__.py b/apps/backend/security/__init__.py index b26311d2..a8b02c03 100644 --- a/apps/backend/security/__init__.py +++ b/apps/backend/security/__init__.py @@ -59,6 +59,7 @@ from .tool_input_validator import ( # Validators (for advanced usage) from .validator import ( VALIDATORS, + validate_bash_command, validate_chmod_command, validate_dropdb_command, validate_dropuser_command, @@ -75,6 +76,9 @@ from .validator import ( validate_psql_command, validate_redis_cli_command, validate_rm_command, + validate_sh_command, + validate_shell_c_command, + validate_zsh_command, ) __all__ = [ @@ -98,6 +102,10 @@ __all__ = [ "validate_git_command", "validate_git_commit", "validate_git_config", + "validate_shell_c_command", + "validate_bash_command", + "validate_sh_command", + "validate_zsh_command", "validate_dropdb_command", "validate_dropuser_command", "validate_psql_command", diff --git a/apps/backend/security/shell_validators.py b/apps/backend/security/shell_validators.py new file mode 100644 index 00000000..f5653df2 --- /dev/null +++ b/apps/backend/security/shell_validators.py @@ -0,0 +1,142 @@ +""" +Shell Interpreter Validators +============================= + +Validators for shell interpreter commands (bash, sh, zsh) that execute +inline commands via the -c flag. + +This closes a security bypass where `bash -c "npm test"` could execute +arbitrary commands since `bash` is in BASE_COMMANDS but the commands +inside -c were not being validated. +""" + +import os +import shlex +from pathlib import Path + +from project_analyzer import is_command_allowed + +from .parser import extract_commands, split_command_segments +from .profile import get_security_profile +from .validation_models import ValidationResult + +# Shell interpreters that can execute nested commands +SHELL_INTERPRETERS = {"bash", "sh", "zsh"} + + +def _extract_c_argument(command_string: str) -> str | None: + """ + Extract the command string from a shell -c invocation. + + Handles various formats: + - bash -c 'command' + - bash -c "command" + - sh -c 'cmd1 && cmd2' + - zsh -c "complex command" + + Args: + command_string: The full shell command (e.g., "bash -c 'npm test'") + + Returns: + The command string after -c, or None if not a -c invocation + """ + try: + tokens = shlex.split(command_string) + except ValueError: + # Malformed command - let it fail safely + return None + + if len(tokens) < 3: + return None + + # Look for -c flag (standalone or combined with other flags like -xc, -ec, -ic) + for i, token in enumerate(tokens): + # Check for standalone -c or combined flags containing 'c' + # Combined flags: -xc, -ec, -ic, -exc, etc. (short options bundled together) + is_c_flag = token == "-c" or ( + token.startswith("-") and not token.startswith("--") and "c" in token[1:] + ) + if is_c_flag and i + 1 < len(tokens): + # The next token is the command to execute + return tokens[i + 1] + + return None + + +def validate_shell_c_command(command_string: str) -> ValidationResult: + """ + Validate commands inside bash/sh/zsh -c '...' strings. + + This prevents using shell interpreters to bypass the security allowlist. + All commands inside the -c string must also be allowed by the profile. + + Args: + command_string: The full shell command (e.g., "bash -c 'npm test'") + + Returns: + Tuple of (is_valid, error_message) + """ + # Extract the command after -c + inner_command = _extract_c_argument(command_string) + + if inner_command is None: + # Not a -c invocation (e.g., "bash script.sh") - allow it + # The script itself would need to be in allowed commands + return True, "" + + # Get the security profile for the current project + # Use PROJECT_DIR_ENV_VAR if set, otherwise use cwd + from .constants import PROJECT_DIR_ENV_VAR + + project_dir = os.environ.get(PROJECT_DIR_ENV_VAR) + if not project_dir: + project_dir = os.getcwd() + + try: + profile = get_security_profile(Path(project_dir)) + except Exception: + # If we can't get the profile, fail safe by blocking + return False, "Could not load security profile to validate shell -c command" + + # Extract command names for allowlist validation + inner_command_names = extract_commands(inner_command) + + if not inner_command_names: + # Could not parse - be permissive for empty commands + # (e.g., bash -c "" is harmless) + if not inner_command.strip(): + return True, "" + return False, f"Could not parse commands inside shell -c: {inner_command}" + + # Validate each command name against the security profile + for cmd_name in inner_command_names: + is_allowed, reason = is_command_allowed(cmd_name, profile) + if not is_allowed: + return ( + False, + f"Command '{cmd_name}' inside shell -c is not allowed: {reason}", + ) + + # Get full command segments for recursive shell validation + # (split_command_segments gives us full commands, not just names) + inner_segments = split_command_segments(inner_command) + + for segment in inner_segments: + # Check if this segment is a shell invocation that needs recursive validation + segment_commands = extract_commands(segment) + if segment_commands: + first_cmd = segment_commands[0] + # Handle paths like /bin/bash + base_cmd = first_cmd.rsplit("/", 1)[-1] if "/" in first_cmd else first_cmd + if base_cmd in SHELL_INTERPRETERS: + valid, err = validate_shell_c_command(segment) + if not valid: + return False, f"Nested shell command not allowed: {err}" + + return True, "" + + +# Alias for common shell interpreters - they all use the same validation +validate_bash_command = validate_shell_c_command +validate_sh_command = validate_shell_c_command +validate_zsh_command = validate_shell_c_command diff --git a/apps/backend/security/validator.py b/apps/backend/security/validator.py index c1ca2898..bfbdd27d 100644 --- a/apps/backend/security/validator.py +++ b/apps/backend/security/validator.py @@ -43,6 +43,12 @@ from .process_validators import ( validate_killall_command, validate_pkill_command, ) +from .shell_validators import ( + validate_bash_command, + validate_sh_command, + validate_shell_c_command, + validate_zsh_command, +) from .validation_models import ValidationResult, ValidatorFunction from .validator_registry import VALIDATORS, get_validator @@ -66,6 +72,11 @@ __all__ = [ "validate_git_commit", "validate_git_command", "validate_git_config", + # Shell validators + "validate_shell_c_command", + "validate_bash_command", + "validate_sh_command", + "validate_zsh_command", # Database validators "validate_dropdb_command", "validate_dropuser_command", diff --git a/apps/backend/security/validator_registry.py b/apps/backend/security/validator_registry.py index 64ad1820..530c0f36 100644 --- a/apps/backend/security/validator_registry.py +++ b/apps/backend/security/validator_registry.py @@ -25,6 +25,11 @@ from .process_validators import ( validate_killall_command, validate_pkill_command, ) +from .shell_validators import ( + validate_bash_command, + validate_sh_command, + validate_zsh_command, +) from .validation_models import ValidatorFunction # Map command names to their validation functions @@ -39,6 +44,10 @@ VALIDATORS: dict[str, ValidatorFunction] = { "init.sh": validate_init_script, # Git "git": validate_git_commit, + # Shell interpreters (validate commands inside -c) + "bash": validate_bash_command, + "sh": validate_sh_command, + "zsh": validate_zsh_command, # Database - PostgreSQL "dropdb": validate_dropdb_command, "dropuser": validate_dropuser_command, diff --git a/tests/test_security.py b/tests/test_security.py index bc6c9d76..a1de8eb7 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -11,28 +11,31 @@ Tests the security.py module functionality including: """ import pytest - +from project_analyzer import BASE_COMMANDS, SecurityProfile from security import ( extract_commands, - split_command_segments, - validate_command, - validate_pkill_command, - validate_kill_command, - validate_chmod_command, - validate_rm_command, - validate_git_commit, - validate_git_config, - validate_dropdb_command, - validate_dropuser_command, - validate_psql_command, - validate_mysql_command, - validate_redis_cli_command, - validate_mongosh_command, - validate_mysqladmin_command, get_command_for_validation, reset_profile_cache, + split_command_segments, + validate_bash_command, + validate_chmod_command, + validate_command, + validate_dropdb_command, + validate_dropuser_command, + validate_git_commit, + validate_git_config, + validate_kill_command, + validate_mongosh_command, + validate_mysql_command, + validate_mysqladmin_command, + validate_pkill_command, + validate_psql_command, + validate_redis_cli_command, + validate_rm_command, + validate_sh_command, + validate_shell_c_command, + validate_zsh_command, ) -from project_analyzer import SecurityProfile, BASE_COMMANDS class TestCommandExtraction: @@ -849,3 +852,735 @@ class TestMysqladminValidator: """Blocks kill.""" allowed, reason = validate_mysqladmin_command("mysqladmin kill 123") assert allowed is False + + +class TestShellCValidator: + """Tests for bash/sh/zsh -c command validation. + + These validators prevent using shell interpreters to bypass the + security allowlist by executing arbitrary commands via -c flag. + """ + + def test_allows_bash_without_c_flag(self): + """Allows bash without -c flag (script execution).""" + allowed, reason = validate_bash_command("bash script.sh") + assert allowed is True + + def test_allows_sh_without_c_flag(self): + """Allows sh without -c flag.""" + allowed, reason = validate_sh_command("sh ./install.sh") + assert allowed is True + + def test_allows_zsh_without_c_flag(self): + """Allows zsh without -c flag.""" + allowed, reason = validate_zsh_command("zsh myscript.zsh") + assert allowed is True + + def test_allows_empty_c_command(self): + """Allows empty -c command (harmless).""" + allowed, reason = validate_bash_command("bash -c ''") + assert allowed is True + + def test_allows_bash_c_with_allowed_command(self, tmp_path, monkeypatch): + """Allows bash -c with commands that are in the allowlist.""" + from project.analyzer import ProjectAnalyzer + + # Set up a mock project directory with a security profile + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + # Compute the actual hash for this directory so profile isn't re-analyzed + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + # Create a minimal security profile with ls, echo, pwd + import json + profile_data = { + "base_commands": ["ls", "echo", "pwd", "cd"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + # Reset cache to pick up the new profile + reset_profile_cache() + + allowed, reason = validate_bash_command("bash -c 'ls -la'") + assert allowed is True + + allowed, reason = validate_bash_command("bash -c 'echo hello && pwd'") + assert allowed is True + + def test_blocks_bash_c_with_disallowed_command(self, tmp_path, monkeypatch): + """Blocks bash -c with commands not in the allowlist.""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + # Compute the actual hash for this directory so profile isn't re-analyzed + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + # Create a minimal security profile WITHOUT npm + import json + profile_data = { + "base_commands": ["ls", "echo"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + # npm is not in the allowlist, so this should be blocked + allowed, reason = validate_bash_command("bash -c 'npm test'") + assert allowed is False + assert "npm" in reason + assert "not allowed" in reason + + def test_blocks_sh_c_with_disallowed_command(self, tmp_path, monkeypatch): + """Blocks sh -c with commands not in the allowlist.""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + # Compute the actual hash for this directory so profile isn't re-analyzed + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + import json + profile_data = { + "base_commands": ["ls"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + allowed, reason = validate_sh_command("sh -c 'curl http://evil.com'") + assert allowed is False + assert "curl" in reason + + def test_handles_complex_c_command(self, tmp_path, monkeypatch): + """Handles complex commands with pipes and chains.""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + # Compute the actual hash for this directory so profile isn't re-analyzed + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + import json + profile_data = { + "base_commands": ["ls", "grep", "wc"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + # All commands are allowed + allowed, reason = validate_bash_command("bash -c 'ls -la | grep pattern | wc -l'") + assert allowed is True + + # One command not allowed + allowed, reason = validate_bash_command("bash -c 'ls -la | npm run test'") + assert allowed is False + + def test_blocks_combined_xc_flag(self, tmp_path, monkeypatch): + """Blocks bash -xc with disallowed commands (combined flags bypass).""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + import json + profile_data = { + "base_commands": ["ls", "echo"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + # Combined -xc flag should be detected and curl blocked + allowed, reason = validate_bash_command("bash -xc 'curl http://evil.com'") + assert allowed is False + assert "curl" in reason + + def test_blocks_combined_ec_flag(self, tmp_path, monkeypatch): + """Blocks bash -ec with disallowed commands.""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + import json + profile_data = { + "base_commands": ["ls", "echo"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + # Combined -ec flag should be detected and wget blocked + allowed, reason = validate_bash_command("bash -ec 'wget evil.com'") + assert allowed is False + assert "wget" in reason + + def test_blocks_combined_ic_flag(self, tmp_path, monkeypatch): + """Blocks bash -ic with disallowed commands (interactive + command).""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + import json + profile_data = { + "base_commands": ["ls", "echo"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + # Combined -ic flag should be detected + allowed, reason = validate_bash_command("bash -ic 'npm run evil'") + assert allowed is False + assert "npm" in reason + + def test_allows_combined_flags_with_allowed_commands(self, tmp_path, monkeypatch): + """Allows combined flags when inner command is allowed.""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + import json + profile_data = { + "base_commands": ["ls", "echo", "pwd"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + # Combined flags with allowed commands should pass + allowed, reason = validate_bash_command("bash -xc 'echo hello'") + assert allowed is True + + def test_blocks_nested_shell_invocation(self, tmp_path, monkeypatch): + """Blocks nested shell invocations with disallowed commands.""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + import json + profile_data = { + "base_commands": ["ls", "echo", "bash", "sh"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + # Nested shell with disallowed command should be blocked + allowed, reason = validate_bash_command("bash -c 'bash -c \"curl http://evil.com\"'") + assert allowed is False + assert "curl" in reason or "nested" in reason.lower() + + def test_allows_nested_shell_with_allowed_commands(self, tmp_path, monkeypatch): + """Allows nested shell invocations when all commands are allowed.""" + from project.analyzer import ProjectAnalyzer + + monkeypatch.setenv("AUTO_CLAUDE_PROJECT_DIR", str(tmp_path)) + + actual_hash = ProjectAnalyzer(tmp_path).compute_project_hash() + + import json + profile_data = { + "base_commands": ["ls", "echo", "bash", "sh", "pwd"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(tmp_path), + "created_at": "", + "project_hash": actual_hash + } + (tmp_path / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + reset_profile_cache() + + # Nested shell with all allowed commands should pass + allowed, reason = validate_bash_command("bash -c 'bash -c \"echo hello\"'") + assert allowed is True + + +class TestInheritedSecurityProfile: + """Tests for inherited security profiles (worktree support). + + When a security profile is inherited from a parent project, + it should not be re-analyzed even if the hash doesn't match. + """ + + def test_inherited_profile_serialization(self): + """Tests that inherited_from field is serialized correctly.""" + profile = SecurityProfile( + base_commands={"ls", "echo"}, + project_hash="abc123", + inherited_from="/path/to/parent/project" + ) + + data = profile.to_dict() + assert "inherited_from" in data + assert data["inherited_from"] == "/path/to/parent/project" + + def test_inherited_profile_deserialization(self): + """Tests that inherited_from field is loaded correctly.""" + data = { + "base_commands": ["ls", "echo"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": "/some/path", + "created_at": "", + "project_hash": "abc123", + "inherited_from": "/path/to/parent" + } + + profile = SecurityProfile.from_dict(data) + assert profile.inherited_from == "/path/to/parent" + + def test_inherited_profile_omits_field_when_empty(self): + """Tests that inherited_from is not in dict when empty (backward compat).""" + profile = SecurityProfile( + base_commands={"ls"}, + project_hash="abc123" + ) + + data = profile.to_dict() + assert "inherited_from" not in data + + def test_should_reanalyze_skips_inherited_profiles(self, tmp_path): + """Tests that inherited profiles from valid parents are never re-analyzed.""" + import json + + from project.analyzer import ProjectAnalyzer + + # Set up a proper parent-child directory structure + parent_dir = tmp_path / "parent" + parent_dir.mkdir() + child_dir = parent_dir / "child" + child_dir.mkdir() + + # Create a valid security profile in the parent + parent_profile_data = { + "base_commands": ["npm", "npx", "node"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(parent_dir), + "created_at": "", + "project_hash": "parent_hash" + } + (parent_dir / ".auto-claude-security.json").write_text(json.dumps(parent_profile_data)) + + # Create a profile with valid inherited_from pointing to actual parent + profile = SecurityProfile( + base_commands={"npm", "npx", "node"}, + project_hash="different_hash_that_would_normally_trigger_reanalysis", + inherited_from=str(parent_dir) + ) + + analyzer = ProjectAnalyzer(child_dir) + + # Even though the hash doesn't match, should_reanalyze should return False + # because inherited_from points to a valid ancestor with a security profile + assert analyzer.should_reanalyze(profile) is False + + def test_should_reanalyze_runs_for_non_inherited_profiles(self, tmp_path): + """Tests that non-inherited profiles are re-analyzed when hash differs.""" + from project.analyzer import ProjectAnalyzer + + # Create a profile WITHOUT inherited_from + profile = SecurityProfile( + base_commands={"ls"}, + project_hash="old_hash_that_doesnt_match" + ) + + analyzer = ProjectAnalyzer(tmp_path) + + # Hash won't match, so should_reanalyze should return True + assert analyzer.should_reanalyze(profile) is True + + def test_should_reanalyze_validates_inherited_from_path(self, tmp_path): + """Tests that inherited_from path is validated before trusting it.""" + import json + + from project.analyzer import ProjectAnalyzer + + # Create a child directory structure + parent_dir = tmp_path / "parent" + parent_dir.mkdir() + child_dir = parent_dir / "child" + child_dir.mkdir() + + # Create a valid parent profile + parent_profile_data = { + "base_commands": ["ls"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(parent_dir), + "created_at": "", + "project_hash": "abc123" + } + (parent_dir / ".auto-claude-security.json").write_text(json.dumps(parent_profile_data)) + + # Create a profile with valid inherited_from (child -> parent) + valid_profile = SecurityProfile( + base_commands={"ls"}, + project_hash="different_hash", + inherited_from=str(parent_dir) + ) + + analyzer = ProjectAnalyzer(child_dir) + + # Valid inherited_from should NOT trigger re-analysis + assert analyzer.should_reanalyze(valid_profile) is False + + def test_should_reanalyze_rejects_invalid_inherited_from_path(self, tmp_path): + """Tests that invalid inherited_from path triggers re-analysis.""" + from project.analyzer import ProjectAnalyzer + + # Create a profile with invalid inherited_from (non-existent path) + invalid_profile = SecurityProfile( + base_commands={"ls"}, + project_hash="different_hash", + inherited_from="/non/existent/path" + ) + + analyzer = ProjectAnalyzer(tmp_path) + + # Invalid inherited_from should trigger re-analysis (falls back to hash check) + assert analyzer.should_reanalyze(invalid_profile) is True + + def test_should_reanalyze_rejects_non_ancestor_inherited_from(self, tmp_path): + """Tests that non-ancestor inherited_from path triggers re-analysis.""" + import json + + from project.analyzer import ProjectAnalyzer + + # Create two unrelated directories + dir_a = tmp_path / "dir_a" + dir_a.mkdir() + dir_b = tmp_path / "dir_b" + dir_b.mkdir() + + # Create a profile in dir_a + profile_data = { + "base_commands": ["ls"], + "stack_commands": [], + "script_commands": [], + "custom_commands": [], + "detected_stack": { + "languages": [], + "package_managers": [], + "frameworks": [], + "databases": [], + "infrastructure": [], + "cloud_providers": [], + "code_quality_tools": [], + "version_managers": [] + }, + "custom_scripts": { + "npm_scripts": [], + "make_targets": [], + "poetry_scripts": [], + "cargo_aliases": [], + "shell_scripts": [] + }, + "project_dir": str(dir_a), + "created_at": "", + "project_hash": "abc123" + } + (dir_a / ".auto-claude-security.json").write_text(json.dumps(profile_data)) + + # Create a profile pointing to dir_a from dir_b (not an ancestor) + spoofed_profile = SecurityProfile( + base_commands={"curl", "wget"}, # Dangerous commands + project_hash="different_hash", + inherited_from=str(dir_a) # dir_a is not an ancestor of dir_b + ) + + analyzer = ProjectAnalyzer(dir_b) + + # Non-ancestor inherited_from should trigger re-analysis + assert analyzer.should_reanalyze(spoofed_profile) is True