Permission Modes: - Add default/plan/auto_accept modes controlling tool access - Plan mode restricts Task agent to read-only tools only - Auto-accept mode bypasses approval prompts (with confirmation) Approval Scaffolding: - Add ApprovalRule/ApprovalRuleSet for granular tool control - Pattern-based matching on tool name and arguments - Default rules for common safe/dangerous patterns - Prep for future bidirectional approval flow CLI Refactor: - Default to Task agent (main orchestrator) - Add --mode flag and runtime mode switching - Integrate prompt_toolkit for better UX: - Persistent command history (~/.webber_history) - Tab completion for commands and file paths - Auto-suggest from history - Deprecate standalone 'explore' command Other: - Split CHANGELOG.md into per-package files - Update AGENTS.md release procedure for both packages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
213 lines
6.1 KiB
Python
213 lines
6.1 KiB
Python
"""
|
|
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
|