mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 14:22:04 +00:00
- Add CI workflow with Python (ruff lint, security tests) and UI (ESLint, TypeScript, build) jobs - Add ruff, mypy, pytest to requirements.txt - Add pyproject.toml with ruff configuration - Fix import sorting across Python files (ruff --fix) - Fix test_security.py expectations to match actual security policy - Remove invalid 'eof' command from ALLOWED_COMMANDS 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
375 lines
10 KiB
Python
375 lines
10 KiB
Python
"""
|
|
Security Hooks for Autonomous Coding Agent
|
|
==========================================
|
|
|
|
Pre-tool-use hooks that validate bash commands for security.
|
|
Uses an allowlist approach - only explicitly permitted commands can run.
|
|
"""
|
|
|
|
import os
|
|
import shlex
|
|
|
|
# Allowed commands for development tasks
|
|
# Minimal set needed for the autonomous coding demo
|
|
ALLOWED_COMMANDS = {
|
|
# File inspection
|
|
"ls",
|
|
"cat",
|
|
"head",
|
|
"tail",
|
|
"wc",
|
|
"grep",
|
|
# File operations (agent uses SDK tools for most file ops, but cp/mkdir needed occasionally)
|
|
"cp",
|
|
"mkdir",
|
|
"chmod", # For making scripts executable; validated separately
|
|
# Directory
|
|
"pwd",
|
|
# Output
|
|
"echo",
|
|
# Node.js development
|
|
"npm",
|
|
"npx",
|
|
"pnpm", # Project uses pnpm
|
|
"node",
|
|
# Version control
|
|
"git",
|
|
# Docker (for PostgreSQL)
|
|
"docker",
|
|
# Process management
|
|
"ps",
|
|
"lsof",
|
|
"sleep",
|
|
"kill", # Kill by PID
|
|
"pkill", # For killing dev servers; validated separately
|
|
# Network/API testing
|
|
"curl",
|
|
# File operations
|
|
"mv",
|
|
"rm", # Use with caution
|
|
"touch",
|
|
# Shell scripts
|
|
"sh",
|
|
"bash",
|
|
# Script execution
|
|
"init.sh", # Init scripts; validated separately
|
|
}
|
|
|
|
# Commands that need additional validation even when in the allowlist
|
|
COMMANDS_NEEDING_EXTRA_VALIDATION = {"pkill", "chmod", "init.sh"}
|
|
|
|
|
|
def split_command_segments(command_string: str) -> list[str]:
|
|
"""
|
|
Split a compound command into individual command segments.
|
|
|
|
Handles command chaining (&&, ||, ;) but not pipes (those are single commands).
|
|
|
|
Args:
|
|
command_string: The full shell command
|
|
|
|
Returns:
|
|
List of individual command segments
|
|
"""
|
|
import re
|
|
|
|
# Split on && and || while preserving the ability to handle each segment
|
|
# This regex splits on && or || that aren't inside quotes
|
|
segments = re.split(r"\s*(?:&&|\|\|)\s*", command_string)
|
|
|
|
# Further split on semicolons
|
|
result = []
|
|
for segment in segments:
|
|
sub_segments = re.split(r'(?<!["\'])\s*;\s*(?!["\'])', segment)
|
|
for sub in sub_segments:
|
|
sub = sub.strip()
|
|
if sub:
|
|
result.append(sub)
|
|
|
|
return result
|
|
|
|
|
|
def extract_commands(command_string: str) -> list[str]:
|
|
"""
|
|
Extract command names from a shell command string.
|
|
|
|
Handles pipes, command chaining (&&, ||, ;), and subshells.
|
|
Returns the base command names (without paths).
|
|
|
|
Args:
|
|
command_string: The full shell command
|
|
|
|
Returns:
|
|
List of command names found in the string
|
|
"""
|
|
commands = []
|
|
|
|
# shlex doesn't treat ; as a separator, so we need to pre-process
|
|
import re
|
|
|
|
# Split on semicolons that aren't inside quotes (simple heuristic)
|
|
# This handles common cases like "echo hello; ls"
|
|
segments = re.split(r'(?<!["\'])\s*;\s*(?!["\'])', command_string)
|
|
|
|
for segment in segments:
|
|
segment = segment.strip()
|
|
if not segment:
|
|
continue
|
|
|
|
try:
|
|
tokens = shlex.split(segment)
|
|
except ValueError:
|
|
# Malformed command (unclosed quotes, etc.)
|
|
# Return empty to trigger block (fail-safe)
|
|
return []
|
|
|
|
if not tokens:
|
|
continue
|
|
|
|
# Track when we expect a command vs arguments
|
|
expect_command = True
|
|
|
|
for token in tokens:
|
|
# Shell operators indicate a new command follows
|
|
if token in ("|", "||", "&&", "&"):
|
|
expect_command = True
|
|
continue
|
|
|
|
# Skip shell keywords that precede commands
|
|
if token in (
|
|
"if",
|
|
"then",
|
|
"else",
|
|
"elif",
|
|
"fi",
|
|
"for",
|
|
"while",
|
|
"until",
|
|
"do",
|
|
"done",
|
|
"case",
|
|
"esac",
|
|
"in",
|
|
"!",
|
|
"{",
|
|
"}",
|
|
):
|
|
continue
|
|
|
|
# Skip flags/options
|
|
if token.startswith("-"):
|
|
continue
|
|
|
|
# Skip variable assignments (VAR=value)
|
|
if "=" in token and not token.startswith("="):
|
|
continue
|
|
|
|
if expect_command:
|
|
# Extract the base command name (handle paths like /usr/bin/python)
|
|
cmd = os.path.basename(token)
|
|
commands.append(cmd)
|
|
expect_command = False
|
|
|
|
return commands
|
|
|
|
|
|
def validate_pkill_command(command_string: str) -> tuple[bool, str]:
|
|
"""
|
|
Validate pkill commands - only allow killing dev-related processes.
|
|
|
|
Uses shlex to parse the command, avoiding regex bypass vulnerabilities.
|
|
|
|
Returns:
|
|
Tuple of (is_allowed, reason_if_blocked)
|
|
"""
|
|
# Allowed process names for pkill
|
|
allowed_process_names = {
|
|
"node",
|
|
"npm",
|
|
"npx",
|
|
"vite",
|
|
"next",
|
|
}
|
|
|
|
try:
|
|
tokens = shlex.split(command_string)
|
|
except ValueError:
|
|
return False, "Could not parse pkill command"
|
|
|
|
if not tokens:
|
|
return False, "Empty pkill command"
|
|
|
|
# Separate flags from arguments
|
|
args = []
|
|
for token in tokens[1:]:
|
|
if not token.startswith("-"):
|
|
args.append(token)
|
|
|
|
if not args:
|
|
return False, "pkill requires a process name"
|
|
|
|
# The target is typically the last non-flag argument
|
|
target = args[-1]
|
|
|
|
# For -f flag (full command line match), extract the first word as process name
|
|
# e.g., "pkill -f 'node server.js'" -> target is "node server.js", process is "node"
|
|
if " " in target:
|
|
target = target.split()[0]
|
|
|
|
if target in allowed_process_names:
|
|
return True, ""
|
|
return False, f"pkill only allowed for dev processes: {allowed_process_names}"
|
|
|
|
|
|
def validate_chmod_command(command_string: str) -> tuple[bool, str]:
|
|
"""
|
|
Validate chmod commands - only allow making files executable with +x.
|
|
|
|
Returns:
|
|
Tuple of (is_allowed, reason_if_blocked)
|
|
"""
|
|
try:
|
|
tokens = shlex.split(command_string)
|
|
except ValueError:
|
|
return False, "Could not parse chmod command"
|
|
|
|
if not tokens or tokens[0] != "chmod":
|
|
return False, "Not a chmod command"
|
|
|
|
# Look for the mode argument
|
|
# Valid modes: +x, u+x, a+x, etc. (anything ending with +x for execute permission)
|
|
mode = None
|
|
files = []
|
|
|
|
for token in tokens[1:]:
|
|
if token.startswith("-"):
|
|
# Skip flags like -R (we don't allow recursive chmod anyway)
|
|
return False, "chmod flags are not allowed"
|
|
elif mode is None:
|
|
mode = token
|
|
else:
|
|
files.append(token)
|
|
|
|
if mode is None:
|
|
return False, "chmod requires a mode"
|
|
|
|
if not files:
|
|
return False, "chmod requires at least one file"
|
|
|
|
# Only allow +x variants (making files executable)
|
|
# This matches: +x, u+x, g+x, o+x, a+x, ug+x, etc.
|
|
import re
|
|
|
|
if not re.match(r"^[ugoa]*\+x$", mode):
|
|
return False, f"chmod only allowed with +x mode, got: {mode}"
|
|
|
|
return True, ""
|
|
|
|
|
|
def validate_init_script(command_string: str) -> tuple[bool, str]:
|
|
"""
|
|
Validate init.sh script execution - only allow ./init.sh.
|
|
|
|
Returns:
|
|
Tuple of (is_allowed, reason_if_blocked)
|
|
"""
|
|
try:
|
|
tokens = shlex.split(command_string)
|
|
except ValueError:
|
|
return False, "Could not parse init script command"
|
|
|
|
if not tokens:
|
|
return False, "Empty command"
|
|
|
|
# The command should be exactly ./init.sh (possibly with arguments)
|
|
script = tokens[0]
|
|
|
|
# Allow ./init.sh or paths ending in /init.sh
|
|
if script == "./init.sh" or script.endswith("/init.sh"):
|
|
return True, ""
|
|
|
|
return False, f"Only ./init.sh is allowed, got: {script}"
|
|
|
|
|
|
def get_command_for_validation(cmd: str, segments: list[str]) -> str:
|
|
"""
|
|
Find the specific command segment that contains the given command.
|
|
|
|
Args:
|
|
cmd: The command name to find
|
|
segments: List of command segments
|
|
|
|
Returns:
|
|
The segment containing the command, or empty string if not found
|
|
"""
|
|
for segment in segments:
|
|
segment_commands = extract_commands(segment)
|
|
if cmd in segment_commands:
|
|
return segment
|
|
return ""
|
|
|
|
|
|
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.
|
|
|
|
Args:
|
|
input_data: Dict containing tool_name and tool_input
|
|
tool_use_id: Optional tool use ID
|
|
context: Optional context
|
|
|
|
Returns:
|
|
Empty dict to allow, or {"decision": "block", "reason": "..."} to block
|
|
"""
|
|
if input_data.get("tool_name") != "Bash":
|
|
return {}
|
|
|
|
command = input_data.get("tool_input", {}).get("command", "")
|
|
if not command:
|
|
return {}
|
|
|
|
# Extract all commands from the command string
|
|
commands = extract_commands(command)
|
|
|
|
if not commands:
|
|
# Could not parse - fail safe by blocking
|
|
return {
|
|
"decision": "block",
|
|
"reason": f"Could not parse command for security validation: {command}",
|
|
}
|
|
|
|
# Split into segments for per-command validation
|
|
segments = split_command_segments(command)
|
|
|
|
# Check each command against the allowlist
|
|
for cmd in commands:
|
|
if cmd not in ALLOWED_COMMANDS:
|
|
return {
|
|
"decision": "block",
|
|
"reason": f"Command '{cmd}' is not in the allowed commands list",
|
|
}
|
|
|
|
# Additional validation for sensitive commands
|
|
if cmd in COMMANDS_NEEDING_EXTRA_VALIDATION:
|
|
# Find the specific segment containing this command
|
|
cmd_segment = get_command_for_validation(cmd, segments)
|
|
if not cmd_segment:
|
|
cmd_segment = command # Fallback to full command
|
|
|
|
if cmd == "pkill":
|
|
allowed, reason = validate_pkill_command(cmd_segment)
|
|
if not allowed:
|
|
return {"decision": "block", "reason": reason}
|
|
elif cmd == "chmod":
|
|
allowed, reason = validate_chmod_command(cmd_segment)
|
|
if not allowed:
|
|
return {"decision": "block", "reason": reason}
|
|
elif cmd == "init.sh":
|
|
allowed, reason = validate_init_script(cmd_segment)
|
|
if not allowed:
|
|
return {"decision": "block", "reason": reason}
|
|
|
|
return {}
|