Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/hookify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ Use environment variables instead of hardcoded values.
- `content`: File content (Write only)

**For prompt events:**
- `user_prompt`: The user's submitted prompt text
- `prompt`: The user's submitted prompt text (`user_prompt` is also accepted for compatibility with older rules)

**For stop events:**
- Use general matching on session state
Expand Down
2 changes: 2 additions & 0 deletions plugins/hookify/core/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ def from_dict(cls, frontmatter: Dict[str, Any], message: str) -> 'Rule':
field = 'command'
elif event == 'file':
field = 'new_text'
elif event == 'prompt':
field = 'prompt'
else:
field = 'content'

Expand Down
34 changes: 29 additions & 5 deletions plugins/hookify/core/rule_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@
from hookify.core.config_loader import Rule, Condition


ADDITIONAL_CONTEXT_EVENTS = {
'SessionStart',
'Setup',
'SubagentStart',
'UserPromptSubmit',
'UserPromptExpansion',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'PostToolBatch',
}


# Cache compiled regexes (max 128 patterns)
@lru_cache(maxsize=128)
def compile_regex(pattern: str) -> re.Pattern:
Expand Down Expand Up @@ -86,13 +99,21 @@ def evaluate_rules(self, rules: List[Rule], input_data: Dict[str, Any]) -> Dict[
# If only warnings, show them but allow operation
if warning_rules:
messages = [f"**[{r.name}]**\n{r.message}" for r in warning_rules]
return {
"systemMessage": "\n\n".join(messages)
}
return self._warning_result(hook_event, "\n\n".join(messages))

# No matches - allow operation
return {}

def _warning_result(self, hook_event: str, message: str) -> Dict[str, Any]:
"""Return a warning response visible to the user and, when supported, Claude."""
result = {"systemMessage": message}
if hook_event in ADDITIONAL_CONTEXT_EVENTS:
result["hookSpecificOutput"] = {
"hookEventName": hook_event,
"additionalContext": message,
}
return result

def _rule_matches(self, rule: Rule, input_data: Dict[str, Any]) -> bool:
"""Check if rule matches input data.

Expand Down Expand Up @@ -224,8 +245,11 @@ def _extract_field(self, field: str, tool_name: str,
print(f"Warning: Encoding error in transcript {transcript_path}: {e}", file=sys.stderr)
return ''
elif field == 'user_prompt':
# For UserPromptSubmit events
return input_data.get('user_prompt', '')
# Backward-compatible alias for older Hookify rules.
return input_data.get('user_prompt') or input_data.get('prompt', '')
elif field == 'prompt':
# UserPromptSubmit events use `prompt` in the hook payload.
return input_data.get('prompt') or input_data.get('user_prompt', '')

# Handle special cases by tool type
if tool_name == 'Bash':
Expand Down
4 changes: 2 additions & 2 deletions plugins/hookify/skills/writing-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ Match user prompt content (advanced):
---
event: prompt
conditions:
- field: user_prompt
- field: prompt
operator: contains
pattern: deploy to production
---
Expand Down Expand Up @@ -368,7 +368,7 @@ Warning message
**Field options:**
- Bash: `command`
- File: `file_path`, `new_text`, `old_text`, `content`
- Prompt: `user_prompt`
- Prompt: `prompt` (`user_prompt` is also accepted for older rules)

**Operators:**
- `regex_match`, `contains`, `equals`, `not_contains`, `starts_with`, `ends_with`
64 changes: 64 additions & 0 deletions plugins/hookify/tests/test_prompt_field_mapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Regression tests for Hookify UserPromptSubmit prompt field handling."""

import sys
import unittest
from pathlib import Path

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT.parent))

from hookify.core.config_loader import Rule
from hookify.core.rule_engine import RuleEngine


class PromptFieldMappingTest(unittest.TestCase):
def test_legacy_prompt_pattern_matches_current_prompt_payload(self):
rule = Rule.from_dict(
{
"name": "catch prompt keyword",
"event": "prompt",
"pattern": "deploy",
},
"Prompt rule fired",
)

result = RuleEngine().evaluate_rules(
[rule],
{
"hook_event_name": "UserPromptSubmit",
"prompt": "please deploy the preview",
},
)

self.assertIn("Prompt rule fired", result["systemMessage"])

def test_user_prompt_alias_still_matches_prompt_payload(self):
rule = Rule.from_dict(
{
"name": "explicit legacy prompt field",
"event": "prompt",
"conditions": [
{
"field": "user_prompt",
"operator": "contains",
"pattern": "deploy",
}
],
},
"Legacy alias fired",
)

result = RuleEngine().evaluate_rules(
[rule],
{
"hook_event_name": "UserPromptSubmit",
"prompt": "please deploy the preview",
},
)

self.assertIn("Legacy alias fired", result["systemMessage"])


if __name__ == "__main__":
unittest.main()
95 changes: 95 additions & 0 deletions plugins/hookify/tests/test_warning_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Regression tests for Hookify warning context output."""

import sys
import unittest
from pathlib import Path

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PLUGIN_ROOT.parent))

from hookify.core.config_loader import Condition, Rule
from hookify.core.rule_engine import RuleEngine


class WarningContextTest(unittest.TestCase):
def test_warning_adds_context_without_auto_approving_pre_tool_use(self):
rule = Rule(
name="warn-publish",
enabled=True,
event="bash",
conditions=[
Condition(field="command", operator="contains", pattern="npm publish")
],
action="warn",
message="Publishing packages requires release approval.",
)

result = RuleEngine().evaluate_rules(
[rule],
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "npm publish"},
},
)

self.assertIn("Publishing packages", result["systemMessage"])
self.assertEqual(
result["systemMessage"],
result["hookSpecificOutput"]["additionalContext"],
)
self.assertEqual("PreToolUse", result["hookSpecificOutput"]["hookEventName"])
self.assertNotIn("permissionDecision", result["hookSpecificOutput"])

def test_warning_adds_context_for_user_prompt_submit(self):
rule = Rule(
name="warn-prod",
enabled=True,
event="prompt",
conditions=[
Condition(field="prompt", operator="contains", pattern="production")
],
action="warn",
message="Production changes need extra care.",
)

result = RuleEngine().evaluate_rules(
[rule],
{
"hook_event_name": "UserPromptSubmit",
"prompt": "deploy this to production",
},
)

self.assertIn("Production changes", result["systemMessage"])
self.assertEqual(
result["systemMessage"],
result["hookSpecificOutput"]["additionalContext"],
)
self.assertEqual("UserPromptSubmit", result["hookSpecificOutput"]["hookEventName"])

def test_warning_omits_context_for_stop_events(self):
rule = Rule(
name="warn-stop",
enabled=True,
event="stop",
conditions=[Condition(field="reason", operator="equals", pattern="done")],
action="warn",
message="Stop warning.",
)

result = RuleEngine().evaluate_rules(
[rule],
{
"hook_event_name": "Stop",
"reason": "done",
},
)

self.assertEqual("**[warn-stop]**\nStop warning.", result["systemMessage"])
self.assertNotIn("hookSpecificOutput", result)


if __name__ == "__main__":
unittest.main()