""" Tool approval evaluation logic. Provides granular control over tool execution: - Rule-based matching on tool name and arguments - Priority-ordered rule evaluation - Default fallback behavior """ import re from typing import Any from src.domains.agents.schemas import ( ApprovalAction, ApprovalRule, ApprovalRuleSet, PermissionMode, ) from src.shared.logging import get_logger logger = get_logger(__name__) def _serialize_tool_args(tool_args: dict[str, Any]) -> str: """ Serialize tool arguments to a string for pattern matching. Converts tool args dict to a consistent string format that can be matched against regex patterns. Examples: {"command": "curl localhost:8095"} -> "command=curl localhost:8095" {"file_path": "/src/main.py"} -> "file_path=/src/main.py" """ parts = [] for key, value in sorted(tool_args.items()): parts.append(f"{key}={value}") return " ".join(parts) def evaluate_rule(rule: ApprovalRule, tool_name: str, tool_args: dict[str, Any]) -> bool: """ Check if a rule matches the given tool call. Args: rule: The approval rule to evaluate tool_name: Name of the tool being called tool_args: Arguments passed to the tool Returns: True if the rule matches, False otherwise """ # Tool name must match exactly if rule.tool != tool_name and rule.tool != "*": return False # Serialize args for pattern matching args_str = _serialize_tool_args(tool_args) # Try to match pattern against serialized args try: if re.search(rule.pattern, args_str, re.IGNORECASE): return True except re.error as e: logger.warning(f"Invalid regex pattern in rule: {rule.pattern} - {e}") return False return False def evaluate_approval( ruleset: ApprovalRuleSet, tool_name: str, tool_args: dict[str, Any], mode: PermissionMode = PermissionMode.default, ) -> ApprovalAction: """ Evaluate whether a tool call should be allowed, denied, or prompt for approval. Args: ruleset: Set of approval rules to evaluate tool_name: Name of the tool being called tool_args: Arguments passed to the tool mode: Current permission mode Returns: ApprovalAction indicating what to do (allow, deny, ask) """ # Plan mode: only read-only tools are even registered, so if we get here # it's a read-only tool and should be allowed if mode == PermissionMode.plan: return ApprovalAction.allow # Auto-accept mode: allow everything without prompting if mode == PermissionMode.auto_accept: return ApprovalAction.allow # Default mode: evaluate rules # Sort rules by priority (highest first) sorted_rules = sorted(ruleset.rules, key=lambda r: r.priority, reverse=True) for rule in sorted_rules: if evaluate_rule(rule, tool_name, tool_args): logger.debug( f"Rule matched: {rule.description or rule.pattern} -> {rule.action}" ) return rule.action # No rules matched, use default action return ruleset.default_action # === Default rule sets === # Read-only tools that never need approval READONLY_TOOLS = {"read_file", "glob_files", "grep_content", "bash_readonly"} # Default rules for common patterns DEFAULT_RULES = ApprovalRuleSet( rules=[ # Always allow read-only tools ApprovalRule( tool="read_file", pattern=".*", action=ApprovalAction.allow, description="Allow all file reads", priority=100, ), ApprovalRule( tool="glob_files", pattern=".*", action=ApprovalAction.allow, description="Allow all glob searches", priority=100, ), ApprovalRule( tool="grep_content", pattern=".*", action=ApprovalAction.allow, description="Allow all grep searches", priority=100, ), ApprovalRule( tool="bash_readonly", pattern=".*", action=ApprovalAction.allow, description="Allow all read-only bash commands", priority=100, ), # Dangerous patterns - always deny ApprovalRule( tool="bash", pattern="rm\\s+-rf\\s+/", action=ApprovalAction.deny, description="Deny recursive delete from root", priority=90, ), ApprovalRule( tool="bash", pattern="sudo\\s+", action=ApprovalAction.deny, description="Deny sudo commands", priority=90, ), # Common safe patterns - allow without prompting ApprovalRule( tool="bash", pattern="command=git\\s+(status|log|diff|show|branch)", action=ApprovalAction.allow, description="Allow read-only git commands", priority=50, ), ApprovalRule( tool="bash", pattern="command=pytest\\s+", action=ApprovalAction.allow, description="Allow pytest execution", priority=50, ), ApprovalRule( tool="bash", pattern="command=python\\s+-m\\s+pytest", action=ApprovalAction.allow, description="Allow pytest via python -m", priority=50, ), ApprovalRule( tool="bash", pattern="command=curl.*localhost", action=ApprovalAction.allow, description="Allow curl to localhost", priority=50, ), ApprovalRule( tool="bash", pattern="command=curl.*127\\.0\\.0\\.1", action=ApprovalAction.allow, description="Allow curl to 127.0.0.1", priority=50, ), ], default_action=ApprovalAction.ask, ) def get_default_ruleset() -> ApprovalRuleSet: """Get the default approval ruleset.""" return DEFAULT_RULES def is_readonly_tool(tool_name: str) -> bool: """Check if a tool is read-only (never needs approval).""" return tool_name in READONLY_TOOLS