| |
| """Rule evaluation engine for hookify plugin.""" |
|
|
| import re |
| import sys |
| from functools import lru_cache |
| from typing import List, Dict, Any, Optional |
|
|
| |
| from hookify.core.config_loader import Rule, Condition |
|
|
|
|
| |
| @lru_cache(maxsize=128) |
| def compile_regex(pattern: str) -> re.Pattern: |
| """Compile regex pattern with caching. |
| |
| Args: |
| pattern: Regex pattern string |
| |
| Returns: |
| Compiled regex pattern |
| """ |
| return re.compile(pattern, re.IGNORECASE) |
|
|
|
|
| class RuleEngine: |
| """Evaluates rules against hook input data.""" |
|
|
| def __init__(self): |
| """Initialize rule engine.""" |
| |
| pass |
|
|
| def evaluate_rules(self, rules: List[Rule], input_data: Dict[str, Any]) -> Dict[str, Any]: |
| """Evaluate all rules and return combined results. |
| |
| Checks all rules and accumulates matches. Blocking rules take priority |
| over warning rules. All matching rule messages are combined. |
| |
| Args: |
| rules: List of Rule objects to evaluate |
| input_data: Hook input JSON (tool_name, tool_input, etc.) |
| |
| Returns: |
| Response dict with systemMessage, hookSpecificOutput, etc. |
| Empty dict {} if no rules match. |
| """ |
| hook_event = input_data.get('hook_event_name', '') |
| blocking_rules = [] |
| warning_rules = [] |
|
|
| for rule in rules: |
| if self._rule_matches(rule, input_data): |
| if rule.action == 'block': |
| blocking_rules.append(rule) |
| else: |
| warning_rules.append(rule) |
|
|
| |
| if blocking_rules: |
| messages = [f"**[{r.name}]**\n{r.message}" for r in blocking_rules] |
| combined_message = "\n\n".join(messages) |
|
|
| |
| if hook_event == 'Stop': |
| return { |
| "decision": "block", |
| "reason": combined_message, |
| "systemMessage": combined_message |
| } |
| elif hook_event in ['PreToolUse', 'PostToolUse']: |
| return { |
| "hookSpecificOutput": { |
| "hookEventName": hook_event, |
| "permissionDecision": "deny" |
| }, |
| "systemMessage": combined_message |
| } |
| else: |
| |
| return { |
| "systemMessage": combined_message |
| } |
|
|
| |
| if warning_rules: |
| messages = [f"**[{r.name}]**\n{r.message}" for r in warning_rules] |
| return { |
| "systemMessage": "\n\n".join(messages) |
| } |
|
|
| |
| return {} |
|
|
| def _rule_matches(self, rule: Rule, input_data: Dict[str, Any]) -> bool: |
| """Check if rule matches input data. |
| |
| Args: |
| rule: Rule to evaluate |
| input_data: Hook input data |
| |
| Returns: |
| True if rule matches, False otherwise |
| """ |
| |
| tool_name = input_data.get('tool_name', '') |
| tool_input = input_data.get('tool_input', {}) |
|
|
| |
| if rule.tool_matcher: |
| if not self._matches_tool(rule.tool_matcher, tool_name): |
| return False |
|
|
| |
| |
| if not rule.conditions: |
| return False |
|
|
| |
| for condition in rule.conditions: |
| if not self._check_condition(condition, tool_name, tool_input, input_data): |
| return False |
|
|
| return True |
|
|
| def _matches_tool(self, matcher: str, tool_name: str) -> bool: |
| """Check if tool_name matches the matcher pattern. |
| |
| Args: |
| matcher: Pattern like "Bash", "Edit|Write", "*" |
| tool_name: Actual tool name |
| |
| Returns: |
| True if matches |
| """ |
| if matcher == '*': |
| return True |
|
|
| |
| patterns = matcher.split('|') |
| return tool_name in patterns |
|
|
| def _check_condition(self, condition: Condition, tool_name: str, |
| tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> bool: |
| """Check if a single condition matches. |
| |
| Args: |
| condition: Condition to check |
| tool_name: Tool being used |
| tool_input: Tool input dict |
| input_data: Full hook input data (for Stop events, etc.) |
| |
| Returns: |
| True if condition matches |
| """ |
| |
| field_value = self._extract_field(condition.field, tool_name, tool_input, input_data) |
| if field_value is None: |
| return False |
|
|
| |
| operator = condition.operator |
| pattern = condition.pattern |
|
|
| if operator == 'regex_match': |
| return self._regex_match(pattern, field_value) |
| elif operator == 'contains': |
| return pattern in field_value |
| elif operator == 'equals': |
| return pattern == field_value |
| elif operator == 'not_contains': |
| return pattern not in field_value |
| elif operator == 'starts_with': |
| return field_value.startswith(pattern) |
| elif operator == 'ends_with': |
| return field_value.endswith(pattern) |
| else: |
| |
| return False |
|
|
| def _extract_field(self, field: str, tool_name: str, |
| tool_input: Dict[str, Any], input_data: Dict[str, Any] = None) -> Optional[str]: |
| """Extract field value from tool input or hook input data. |
| |
| Args: |
| field: Field name like "command", "new_text", "file_path", "reason", "transcript" |
| tool_name: Tool being used (may be empty for Stop events) |
| tool_input: Tool input dict |
| input_data: Full hook input (for accessing transcript_path, reason, etc.) |
| |
| Returns: |
| Field value as string, or None if not found |
| """ |
| |
| if field in tool_input: |
| value = tool_input[field] |
| if isinstance(value, str): |
| return value |
| return str(value) |
|
|
| |
| if input_data: |
| |
| if field == 'reason': |
| return input_data.get('reason', '') |
| elif field == 'transcript': |
| |
| transcript_path = input_data.get('transcript_path') |
| if transcript_path: |
| try: |
| with open(transcript_path, 'r') as f: |
| return f.read() |
| except FileNotFoundError: |
| print(f"Warning: Transcript file not found: {transcript_path}", file=sys.stderr) |
| return '' |
| except PermissionError: |
| print(f"Warning: Permission denied reading transcript: {transcript_path}", file=sys.stderr) |
| return '' |
| except (IOError, OSError) as e: |
| print(f"Warning: Error reading transcript {transcript_path}: {e}", file=sys.stderr) |
| return '' |
| except UnicodeDecodeError as e: |
| print(f"Warning: Encoding error in transcript {transcript_path}: {e}", file=sys.stderr) |
| return '' |
| elif field == 'user_prompt': |
| |
| return input_data.get('user_prompt', '') |
|
|
| |
| if tool_name == 'Bash': |
| if field == 'command': |
| return tool_input.get('command', '') |
|
|
| elif tool_name in ['Write', 'Edit']: |
| if field == 'content': |
| |
| return tool_input.get('content') or tool_input.get('new_string', '') |
| elif field == 'new_text' or field == 'new_string': |
| return tool_input.get('new_string', '') |
| elif field == 'old_text' or field == 'old_string': |
| return tool_input.get('old_string', '') |
| elif field == 'file_path': |
| return tool_input.get('file_path', '') |
|
|
| elif tool_name == 'MultiEdit': |
| if field == 'file_path': |
| return tool_input.get('file_path', '') |
| elif field in ['new_text', 'content']: |
| |
| edits = tool_input.get('edits', []) |
| return ' '.join(e.get('new_string', '') for e in edits) |
|
|
| return None |
|
|
| def _regex_match(self, pattern: str, text: str) -> bool: |
| """Check if pattern matches text using regex. |
| |
| Args: |
| pattern: Regex pattern |
| text: Text to match against |
| |
| Returns: |
| True if pattern matches |
| """ |
| try: |
| |
| regex = compile_regex(pattern) |
| return bool(regex.search(text)) |
|
|
| except re.error as e: |
| print(f"Invalid regex pattern '{pattern}': {e}", file=sys.stderr) |
| return False |
|
|
|
|
| |
| if __name__ == '__main__': |
| from hookify.core.config_loader import Condition, Rule |
|
|
| |
| rule = Rule( |
| name="test-rm", |
| enabled=True, |
| event="bash", |
| conditions=[ |
| Condition(field="command", operator="regex_match", pattern=r"rm\s+-rf") |
| ], |
| message="Dangerous rm command!" |
| ) |
|
|
| engine = RuleEngine() |
|
|
| |
| test_input = { |
| "tool_name": "Bash", |
| "tool_input": { |
| "command": "rm -rf /tmp/test" |
| } |
| } |
|
|
| result = engine.evaluate_rules([rule], test_input) |
| print("Match result:", result) |
|
|
| |
| test_input2 = { |
| "tool_name": "Bash", |
| "tool_input": { |
| "command": "ls -la" |
| } |
| } |
|
|
| result2 = engine.evaluate_rules([rule], test_input2) |
| print("Non-match result:", result2) |
|
|