From 7dabee3e10401c913b0a2c3637f5bbd2c085dc8f Mon Sep 17 00:00:00 2001 From: nyxst4ck <289980115+nyxst4ck@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:01:16 -0300 Subject: [PATCH] fix(hookify): handle prompt fields and warning context --- plugins/hookify/README.md | 2 +- plugins/hookify/core/config_loader.py | 2 + plugins/hookify/core/rule_engine.py | 34 ++++++- plugins/hookify/skills/writing-rules/SKILL.md | 4 +- .../tests/test_prompt_field_mapping.py | 64 +++++++++++++ plugins/hookify/tests/test_warning_context.py | 95 +++++++++++++++++++ 6 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 plugins/hookify/tests/test_prompt_field_mapping.py create mode 100644 plugins/hookify/tests/test_warning_context.py diff --git a/plugins/hookify/README.md b/plugins/hookify/README.md index 1aca6cdfb7..547d426e86 100644 --- a/plugins/hookify/README.md +++ b/plugins/hookify/README.md @@ -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 diff --git a/plugins/hookify/core/config_loader.py b/plugins/hookify/core/config_loader.py index fa2fc3e36f..00441f4910 100644 --- a/plugins/hookify/core/config_loader.py +++ b/plugins/hookify/core/config_loader.py @@ -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' diff --git a/plugins/hookify/core/rule_engine.py b/plugins/hookify/core/rule_engine.py index 8244c00591..9a78dcc0c5 100644 --- a/plugins/hookify/core/rule_engine.py +++ b/plugins/hookify/core/rule_engine.py @@ -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: @@ -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. @@ -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': diff --git a/plugins/hookify/skills/writing-rules/SKILL.md b/plugins/hookify/skills/writing-rules/SKILL.md index 008168a4c9..d08c684a1d 100644 --- a/plugins/hookify/skills/writing-rules/SKILL.md +++ b/plugins/hookify/skills/writing-rules/SKILL.md @@ -208,7 +208,7 @@ Match user prompt content (advanced): --- event: prompt conditions: - - field: user_prompt + - field: prompt operator: contains pattern: deploy to production --- @@ -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` diff --git a/plugins/hookify/tests/test_prompt_field_mapping.py b/plugins/hookify/tests/test_prompt_field_mapping.py new file mode 100644 index 0000000000..8f49ede347 --- /dev/null +++ b/plugins/hookify/tests/test_prompt_field_mapping.py @@ -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() diff --git a/plugins/hookify/tests/test_warning_context.py b/plugins/hookify/tests/test_warning_context.py new file mode 100644 index 0000000000..10b9dfafbc --- /dev/null +++ b/plugins/hookify/tests/test_warning_context.py @@ -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()