feat: add per-project bash command allowlist system

Implement hierarchical command security with project and org-level configs:

WHAT'S NEW:
- Project-level YAML config (.autocoder/allowed_commands.yaml)
- Organization-level config (~/.autocoder/config.yaml)
- Pattern matching (exact, wildcards, local scripts)
- Hardcoded blocklist (sudo, dd, shutdown - never allowed)
- Org blocklist (terraform, kubectl - configurable)
- Helpful error messages with config hints
- Comprehensive documentation and examples

ARCHITECTURE:
- Hierarchical resolution: Hardcoded → Org Block → Org Allow → Global → Project
- YAML validation with 50 command limit per project
- Pattern matching: exact ("swift"), wildcards ("swift*"), scripts ("./build.sh")
- Secure by default: all examples commented out

TESTING:
- 136 unit tests (pattern matching, YAML, hierarchy, validation)
- 9 integration tests (real security hook flows)
- All tests passing, 100% backward compatible

DOCUMENTATION:
- examples/README.md - comprehensive guide with use cases
- examples/project_allowed_commands.yaml - template (all commented)
- examples/org_config.yaml - org config template (all commented)
- PHASE3_SPEC.md - mid-session approval spec (future enhancement)
- Updated CLAUDE.md with security model documentation

USE CASES:
- iOS projects: Add Swift toolchain (xcodebuild, swift*, etc.)
- Rust projects: Add cargo, rustc, clippy
- Enterprise: Block aws, kubectl, terraform org-wide
- Custom scripts: Allow ./scripts/build.sh

PHASES:
 Phase 1: Project YAML + blocklist (implemented)
 Phase 2: Org config + hierarchy (implemented)
📋 Phase 3: Mid-session approval (spec ready, not implemented)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Marian Paul
2026-01-22 12:16:16 +01:00
parent 29c6b252a9
commit a9a0fcd865
11 changed files with 3789 additions and 8 deletions

View File

@@ -8,6 +8,10 @@ Uses an allowlist approach - only explicitly permitted commands can run.
import os
import shlex
from pathlib import Path
from typing import Optional
import yaml
# Allowed commands for development tasks
# Minimal set needed for the autonomous coding demo
@@ -58,6 +62,48 @@ ALLOWED_COMMANDS = {
# Commands that need additional validation even when in the allowlist
COMMANDS_NEEDING_EXTRA_VALIDATION = {"pkill", "chmod", "init.sh"}
# Commands that are NEVER allowed, even with user approval
# These commands can cause permanent system damage or security breaches
BLOCKED_COMMANDS = {
# Disk operations
"dd",
"mkfs",
"fdisk",
"parted",
# System control
"shutdown",
"reboot",
"poweroff",
"halt",
"init",
# Ownership changes
"chown",
"chgrp",
# System services
"systemctl",
"service",
"launchctl",
# Network security
"iptables",
"ufw",
}
# Commands that trigger emphatic warnings but CAN be approved (Phase 3)
# For now, these are blocked like BLOCKED_COMMANDS until Phase 3 implements approval
DANGEROUS_COMMANDS = {
# Privilege escalation
"sudo",
"su",
"doas",
# Cloud CLIs (can modify production infrastructure)
"aws",
"gcloud",
"az",
# Container and orchestration
"kubectl",
"docker-compose",
}
def split_command_segments(command_string: str) -> list[str]:
"""
@@ -309,16 +355,298 @@ def get_command_for_validation(cmd: str, segments: list[str]) -> str:
return ""
def matches_pattern(command: str, pattern: str) -> bool:
"""
Check if a command matches a pattern.
Supports:
- Exact match: "swift"
- Prefix wildcard: "swift*" matches "swift", "swiftc", "swiftformat"
- Local script paths: "./scripts/build.sh" or "scripts/test.sh"
Args:
command: The command to check
pattern: The pattern to match against
Returns:
True if command matches pattern
"""
# Exact match
if command == pattern:
return True
# Prefix wildcard (e.g., "swift*" matches "swiftc", "swiftlint")
if pattern.endswith("*"):
prefix = pattern[:-1]
return command.startswith(prefix)
# Local script paths (./scripts/build.sh matches build.sh)
if pattern.startswith("./") or pattern.startswith("../"):
# Extract the script name from the pattern
pattern_name = os.path.basename(pattern)
return command == pattern or command == pattern_name or command.endswith("/" + pattern_name)
return False
def get_org_config_path() -> Path:
"""
Get the organization-level config file path.
Returns:
Path to ~/.autocoder/config.yaml
"""
return Path.home() / ".autocoder" / "config.yaml"
def load_org_config() -> Optional[dict]:
"""
Load organization-level config from ~/.autocoder/config.yaml.
Returns:
Dict with parsed org config, or None if file doesn't exist or is invalid
"""
config_path = get_org_config_path()
if not config_path.exists():
return None
try:
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
if not config:
return None
# Validate structure
if not isinstance(config, dict):
return None
if "version" not in config:
return None
# Validate allowed_commands if present
if "allowed_commands" in config:
allowed = config["allowed_commands"]
if not isinstance(allowed, list):
return None
for cmd in allowed:
if not isinstance(cmd, dict):
return None
if "name" not in cmd:
return None
# Validate blocked_commands if present
if "blocked_commands" in config:
blocked = config["blocked_commands"]
if not isinstance(blocked, list):
return None
for cmd in blocked:
if not isinstance(cmd, str):
return None
return config
except (yaml.YAMLError, IOError, OSError):
return None
def load_project_commands(project_dir: Path) -> Optional[dict]:
"""
Load allowed commands from project-specific YAML config.
Args:
project_dir: Path to the project directory
Returns:
Dict with parsed YAML config, or None if file doesn't exist or is invalid
"""
config_path = project_dir / ".autocoder" / "allowed_commands.yaml"
if not config_path.exists():
return None
try:
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
if not config:
return None
# Validate structure
if not isinstance(config, dict):
return None
if "version" not in config:
return None
commands = config.get("commands", [])
if not isinstance(commands, list):
return None
# Enforce 50 command limit
if len(commands) > 50:
return None
# Validate each command entry
for cmd in commands:
if not isinstance(cmd, dict):
return None
if "name" not in cmd:
return None
# Validate name is a string
if not isinstance(cmd["name"], str):
return None
return config
except (yaml.YAMLError, IOError, OSError):
return None
def validate_project_command(cmd_config: dict) -> tuple[bool, str]:
"""
Validate a single command entry from project config.
Args:
cmd_config: Dict with command configuration (name, description, args)
Returns:
Tuple of (is_valid, error_message)
"""
if not isinstance(cmd_config, dict):
return False, "Command must be a dict"
if "name" not in cmd_config:
return False, "Command must have 'name' field"
name = cmd_config["name"]
if not isinstance(name, str) or not name:
return False, "Command name must be a non-empty string"
# Check if command is in the blocklist or dangerous commands
base_cmd = os.path.basename(name.rstrip("*"))
if base_cmd in BLOCKED_COMMANDS:
return False, f"Command '{name}' is in the blocklist and cannot be allowed"
if base_cmd in DANGEROUS_COMMANDS:
return False, f"Command '{name}' is in the blocklist and cannot be allowed"
# Description is optional
if "description" in cmd_config and not isinstance(cmd_config["description"], str):
return False, "Description must be a string"
# Args validation (Phase 1 - just check structure)
if "args" in cmd_config:
args = cmd_config["args"]
if not isinstance(args, list):
return False, "Args must be a list"
for arg in args:
if not isinstance(arg, str):
return False, "Each arg must be a string"
return True, ""
def get_effective_commands(project_dir: Optional[Path]) -> tuple[set[str], set[str]]:
"""
Get effective allowed and blocked commands after hierarchy resolution.
Hierarchy (highest to lowest priority):
1. BLOCKED_COMMANDS (hardcoded) - always blocked
2. Org blocked_commands - cannot be unblocked
3. Org allowed_commands - adds to global
4. Project allowed_commands - adds to global + org
Args:
project_dir: Path to the project directory, or None
Returns:
Tuple of (allowed_commands, blocked_commands)
"""
# Start with global allowed commands
allowed = ALLOWED_COMMANDS.copy()
blocked = BLOCKED_COMMANDS.copy()
# Add dangerous commands to blocked (Phase 3 will add approval flow)
blocked |= DANGEROUS_COMMANDS
# Load org config and apply
org_config = load_org_config()
if org_config:
# Add org-level blocked commands (cannot be overridden)
org_blocked = org_config.get("blocked_commands", [])
blocked |= set(org_blocked)
# Add org-level allowed commands
for cmd_config in org_config.get("allowed_commands", []):
if isinstance(cmd_config, dict) and "name" in cmd_config:
allowed.add(cmd_config["name"])
# Load project config and apply
if project_dir:
project_config = load_project_commands(project_dir)
if project_config:
# Add project-specific commands
for cmd_config in project_config.get("commands", []):
valid, error = validate_project_command(cmd_config)
if valid:
allowed.add(cmd_config["name"])
# Remove blocked commands from allowed (blocklist takes precedence)
allowed -= blocked
return allowed, blocked
def get_project_allowed_commands(project_dir: Optional[Path]) -> set[str]:
"""
Get the set of allowed commands for a project.
Uses hierarchy resolution from get_effective_commands().
Args:
project_dir: Path to the project directory, or None
Returns:
Set of allowed command names (including patterns)
"""
allowed, blocked = get_effective_commands(project_dir)
return allowed
def is_command_allowed(command: str, allowed_commands: set[str]) -> bool:
"""
Check if a command is allowed (supports patterns).
Args:
command: The command to check
allowed_commands: Set of allowed commands (may include patterns)
Returns:
True if command is allowed
"""
# Check exact match first
if command in allowed_commands:
return True
# Check pattern matches
for pattern in allowed_commands:
if matches_pattern(command, pattern):
return True
return False
async def bash_security_hook(input_data, tool_use_id=None, context=None):
"""
Pre-tool-use hook that validates bash commands using an allowlist.
Only commands in ALLOWED_COMMANDS are permitted.
Only commands in ALLOWED_COMMANDS and project-specific commands are permitted.
Args:
input_data: Dict containing tool_name and tool_input
tool_use_id: Optional tool use ID
context: Optional context
context: Optional context dict with 'project_dir' key
Returns:
Empty dict to allow, or {"decision": "block", "reason": "..."} to block
@@ -340,15 +668,39 @@ async def bash_security_hook(input_data, tool_use_id=None, context=None):
"reason": f"Could not parse command for security validation: {command}",
}
# Get project directory from context
project_dir = None
if context and isinstance(context, dict):
project_dir_str = context.get("project_dir")
if project_dir_str:
project_dir = Path(project_dir_str)
# Get effective commands using hierarchy resolution
allowed_commands, blocked_commands = get_effective_commands(project_dir)
# Split into segments for per-command validation
segments = split_command_segments(command)
# Check each command against the allowlist
# Check each command against the blocklist and allowlist
for cmd in commands:
if cmd not in ALLOWED_COMMANDS:
# Check blocklist first (highest priority)
if cmd in blocked_commands:
return {
"decision": "block",
"reason": f"Command '{cmd}' is not in the allowed commands list",
"reason": f"Command '{cmd}' is blocked at organization level and cannot be approved.",
}
# Check allowlist (with pattern matching)
if not is_command_allowed(cmd, allowed_commands):
# Provide helpful error message with config hint
error_msg = f"Command '{cmd}' is not allowed.\n"
error_msg += "To allow this command:\n"
error_msg += " 1. Add to .autocoder/allowed_commands.yaml for this project, OR\n"
error_msg += " 2. Request mid-session approval (the agent can ask)\n"
error_msg += "Note: Some commands are blocked at org-level and cannot be overridden."
return {
"decision": "block",
"reason": error_msg,
}
# Additional validation for sensitive commands