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 <noreply@anthropic.com> * style: fix import ordering in test_security.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: format shell_validators.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+752
-17
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user