{"repo":"NousResearch/hermes-agent","public_id":"10572","display_label":"Webhook payload filtering and route scripting feature","summary":"These members propose adding payload filter and route script support to gateway webhooks. The feature would allow users to define filtering logic or scripting parameters on webhook configurations.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60944,"merged_at":"2026-07-08T15:10:56+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-08T15:10:56+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":10572,"pr_number":60944,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T15:10:56+00:00"},{"issue_number":18041,"pr_number":60944,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-08T15:10:56+00:00"}],"members":[{"kind":"issue","number":10572,"title":"[Feature]: Support payload filtering or script param in webhooks","url":null,"state":"closed"},{"kind":"pr","number":57544,"title":"feat(gateway): add webhook payload filters and route scripts","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60944","number":60944}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60944,"title":"feat(gateway): webhook payload filters and route scripts (salvage #57544)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cli-config.yaml.example","gateway/platforms/webhook.py","gateway/platforms/webhook_filters.py","hermes_cli/subcommands/webhook.py","hermes_cli/web_server.py","hermes_cli/webhook.py","scripts/release.py","tests/gateway/test_webhook_adapter.py","tests/hermes_cli/test_dashboard_admin_endpoints.py","tests/hermes_cli/test_webhook_cli.py","website/docs/guides/webhook-github-pr-review.md","website/docs/user-guide/messaging/webhooks.md"],"fix_hunks":[{"file":"cli-config.yaml.example","hunks":["@@ -846,6 +846,11 @@ platform_toolsets:\n #         priority_mode: prepend\n #         priority:\n #           - my_plugin_command\n+#   webhook:\n+#     extra:\n+#       # Route scripts default to a 30 second timeout. Scripts must live under\n+#       # the active profile's scripts directory and receive webhook JSON on stdin.\n+#       script_timeout_seconds: 30\n #\n # Discord-specific settings (config.yaml top-level, not under platforms:):\n #"]},{"file":"gateway/platforms/webhook.py","hunks":["@@ -58,6 +58,10 @@\n     MessageType,\n     SendResult,\n )\n+from gateway.platforms.webhook_filters import (\n+    DEFAULT_SCRIPT_TIMEOUT_SECONDS,\n+    WebhookRouteProcessor,\n+)\n \n logger = logging.getLogger(__name__)\n ","@@ -78,7 +82,6 @@\n _INSECURE_NO_AUTH = \"INSECURE_NO_AUTH\"\n _DYNAMIC_ROUTES_FILENAME = \"webhook_subscriptions.json\"\n _RATE_WINDOW_SECONDS = 60.0\n-\n # Hostnames/IP literals that only serve connections originating on the same\n # machine. Anything else is treated as a public bind for safety-rail purposes.\n _LOOPBACK_HOSTS = frozenset({","@@ -155,6 +158,15 @@ def __init__(self, config: PlatformConfig):\n         self._max_body_bytes: int = int(\n             config.extra.get(\"max_body_bytes\", 1_048_576)\n         )  # 1MB\n+        self._script_timeout_seconds: int = int(\n+            config.extra.get(\n+                \"script_timeout_seconds\",\n+                DEFAULT_SCRIPT_TIMEOUT_SECONDS,\n+            )\n+        )\n+        self._route_processor = WebhookRouteProcessor(\n+            script_timeout_seconds=self._script_timeout_seconds\n+        )\n \n     # ------------------------------------------------------------------\n     # Lifecycle","@@ -571,6 +583,45 @@ async def _handle_webhook(self, request: \"web.Request\") -> \"web.Response\":\n                 {\"status\": \"ignored\", \"event\": event_type}\n             )\n \n+        if not self._route_processor.route_filters_match(\n+            route_config, payload, event_type, request.headers\n+        ):\n+            logger.info(\n+                \"[webhook] filtered event=%s route=%s\",\n+                event_type,\n+                route_name,\n+            )\n+            return web.json_response(\n+                {\n+                    \"status\": \"ignored\",\n+                    \"reason\": \"filter\",\n+                    \"route\": route_name,\n+                }\n+            )\n+\n+        if route_config.get(\"script\"):\n+            # run_route_script shells out (subprocess.run, up to its timeout);\n+            # run it in a worker thread so it can't block the gateway event loop.\n+            keep, transformed_payload = await asyncio.to_thread(\n+                self._route_processor.run_route_script,\n+                route_config.get(\"script\"),\n+                payload,\n+            )\n+            if not keep:\n+                logger.info(\n+                    \"[webhook] script ignored event=%s route=%s\",\n+                    event_type,\n+                    route_name,\n+                )\n+                return web.json_response(\n+                    {\n+                        \"status\": \"ignored\",\n+                        \"reason\": \"script\",\n+                        \"route\": route_name,\n+                    }\n+                )\n+            payload = transformed_payload or payload\n+\n         # Format prompt from template\n         prompt_template = route_config.get(\"prompt\", \"\")\n         prompt = self._render_prompt("]},{"file":"gateway/platforms/webhook_filters.py","hunks":["@@ -0,0 +1,302 @@\n+\"\"\"Route-local filters and script transforms for the webhook adapter.\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import logging\n+import os\n+import re\n+import shutil\n+import subprocess\n+import sys\n+from pathlib import Path\n+from typing import Any, Optional\n+\n+logger = logging.getLogger(__name__)\n+\n+DEFAULT_SCRIPT_TIMEOUT_SECONDS = 30\n+_MISSING = object()\n+\n+\n+def _stringify_filter_value(value: Any) -> str:\n+    if value is _MISSING:\n+        return \"\"\n+    if isinstance(value, (dict, list)):\n+        return json.dumps(value, sort_keys=True)\n+    return str(value)\n+\n+\n+def _resolve_profile_path(path_value: Any) -> Optional[Path]:\n+    \"\"\"Resolve a user path, mapping ~/.hermes to the active profile home.\"\"\"\n+    if not isinstance(path_value, str):\n+        return None\n+    raw = os.path.expandvars(path_value.strip())\n+    if not raw:\n+        return None\n+    from hermes_constants import get_hermes_home\n+\n+    hermes_home = get_hermes_home()\n+    if raw == \"~/.hermes\":\n+        return hermes_home\n+    if raw.startswith(\"~/.hermes/\"):\n+        return hermes_home / raw.removeprefix(\"~/.hermes/\")\n+    path = Path(raw).expanduser()\n+    if path.is_absolute():\n+        return path\n+    return hermes_home / path\n+\n+\n+def _resolve_script_path(script_value: Any) -> tuple[Optional[Path], Optional[str]]:\n+    \"\"\"Resolve a route script under HERMES_HOME/scripts.\"\"\"\n+    if not isinstance(script_value, str) or not script_value.strip():\n+        return None, \"script path is empty\"\n+    from hermes_constants import get_hermes_home\n+\n+    scripts_root = (get_hermes_home() / \"scripts\").resolve()\n+    raw_text = os.path.expandvars(script_value.strip())\n+    if raw_text == \"~/.hermes\" or raw_text.startswith(\"~/.hermes/\"):\n+        mapped = _resolve_profile_path(raw_text)\n+        candidate = mapped.resolve() if mapped is not None else scripts_root\n+    else:\n+        raw = Path(raw_text).expanduser()\n+        candidate = raw.resolve() if raw.is_absolute() else (scripts_root / raw).resolve()\n+    try:\n+        candidate.relative_to(scripts_root)\n+    except ValueError:\n+        return None, f\"script path resolves outside {scripts_root}\"\n+    if not candidate.exists():\n+        return None, f\"script not found: {candidate}\"\n+    if not candidate.is_file():\n+        return None, f\"script path is not a file: {candidate}\"\n+    return candidate, None\n+\n+\n+def _load_filter_file_values(path_value: Any) -> list[Any]:\n+    path = _resolve_profile_path(path_value)\n+    if path is None:\n+        return []\n+    try:\n+        raw = path.read_text(encoding=\"utf-8\")\n+    except OSError as exc:\n+        logger.warning(\"[webhook] filter in_file read failed for %s: %s\", path, exc)\n+        return []\n+    try:\n+        data = json.loads(raw)\n+    except json.JSONDecodeError:\n+        return [line.strip() for line in raw.splitlines() if line.strip()]\n+    if isinstance(data, list):\n+        return data\n+    if isinstance(data, dict):\n+        return list(data.keys())\n+    return [data]\n+\n+\n+class WebhookRouteProcessor:\n+    \"\"\"Evaluate declarative filters and optional script transforms.\"\"\"\n+\n+    def __init__(\n+        self,\n+        *,\n+        script_timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS,\n+    ) -> None:\n+        self.script_timeout_seconds = max(1, int(script_timeout_seconds))\n+\n+    def resolve_filter_field(\n+        self,\n+        field: Any,\n+        payload: dict,\n+        event_type: str,\n+        headers: Any,\n+    ) -> Any:\n+        \"\"\"Resolve a dotted filter field against payload/event/headers context.\"\"\"\n+        if not isinstance(field, str) or not field.strip():\n+            return _MISSING\n+        parts = [part for part in field.strip().split(\".\") if part]\n+        if not parts:\n+            return _MISSING\n+        header_dict = dict(headers or {})\n+        context = {\n+            \"payload\": payload.get(\"payload\", payload),\n+            \"event\": event_type,\n+            \"event_type\": event_type,\n+            \"headers\": header_dict,\n+        }\n+        if parts[0] in context:\n+            value: Any = context[parts[0]]\n+            parts = parts[1:]\n+        else:\n+            value = payload\n+        for part in parts:\n+            if isinstance(value, dict):\n+                value = value.get(part, _MISSING)\n+            elif isinstance(value, list) and part.isdigit():\n+                idx = int(part)\n+                value = value[idx] if 0 <= idx < len(value) else _MISSING\n+            else:\n+                return _MISSING\n+            if value is _MISSING:\n+                return _MISSING\n+        return value\n+\n+    def filter_matches(\n+        self,\n+        spec: Any,\n+        payload: dict,\n+        event_type: str,\n+        headers: Any,\n+    ) -> bool:\n+        \"\"\"Evaluate one declarative webhook filter spec.\"\"\"\n+        if not isinstance(spec, dict):\n+            logger.warning(\"[webhook] Ignoring invalid filter spec: %r\", spec)\n+            return False\n+\n+        if \"all\" in spec:\n+            items = spec.get(\"all\")\n+            return isinstance(items, list) and all(\n+                self.filter_matches(item, payload, event_type, headers)\n+                for item in items\n+            )\n+        if \"any\" in spec:\n+            items = spec.get(\"any\")\n+            return isinstance(items, list) and any(\n+                self.filter_matches(item, payload, event_type, headers)\n+                for item in items\n+            )\n+        if \"not\" in spec:\n+            return not self.filter_matches(spec.get(\"not\"), payload, event_type, headers)\n+\n+        value = self.resolve_filter_field(\n+            spec.get(\"field\"), payload, event_type, headers\n+        )\n+\n+        if \"exists\" in spec:\n+            exists = value is not _MISSING\n+            return exists is bool(spec.get(\"exists\"))\n+        if spec.get(\"missing\") is True:\n+            return value is _MISSING\n+        if \"equals\" in spec:\n+            return value is not _MISSING and value == spec.get(\"equals\")\n+        if \"not_equals\" in spec:\n+            return value is _MISSING or value != spec.get(\"not_equals\")\n+        if \"contains\" in spec:\n+            needle = spec.get(\"contains\")\n+            if value is _MISSING:\n+                return False\n+            if isinstance(value, (list, tuple, set, dict)):\n+                return needle in value\n+            return str(needle) in _stringify_filter_value(value)\n+        if \"in\" in spec:\n+            haystack = spec.get(\"in\")\n+            return isinstance(haystack, list) and value in haystack\n+        if \"in_file\" in spec:\n+            return value in _load_filter_file_values(spec.get(\"in_file\"))\n+        if \"regex\" in spec:\n+            if value is _MISSING:\n+                return False\n+            try:\n+                return (\n+                    re.search(str(spec.get(\"regex\")), _stringify_filter_value(value))\n+                    is not None\n+                )\n+            except re.error as exc:\n+                logger.warning(\"[webhook] Invalid webhook filter regex: %s\", exc)\n+                return False\n+\n+        logger.warning(\"[webhook] Filter spec has no supported operator: %r\", spec)\n+        return False\n+\n+    def route_filters_match(\n+        self,\n+        route_config: dict,\n+        payload: dict,\n+        event_type: str,\n+        headers: Any,\n+    ) -> bool:\n+        filters = route_config.get(\"filters\") or []\n+        if not filters:\n+            return True\n+        if isinstance(filters, dict):\n+            return self.filter_matches(filters, payload, event_type, headers)\n+        if not isinstance(filters, list):\n+            logger.warning(\"[webhook] filters must be a list or object\")\n+            return False\n+        return all(\n+            self.filter_matches(spec, payload, event_type, headers)\n+            for spec in filters\n+        )\n+\n+    def run_route_script(self, script_value: Any, payload: dict) -> tuple[bool, Optional[dict]]:\n+        \"\"\"Run a route script and return (should_continue, transformed_payload).\"\"\"\n+        path, error = _resolve_script_path(script_value)\n+        if error or path is None:\n+            logger.warning(\"[webhook] script ignored webhook: %s\", error)\n+            return False, None\n+\n+        suffix = path.suffix.lower()\n+        if suffix in {\".sh\", \".bash\"}:\n+            bash = shutil.which(\"bash\") or (\n+                \"/bin/bash\" if os.path.isfile(\"/bin/bash\") else None\n+            )\n+            if bash is None:\n+                logger.warning(\"[webhook] script ignored webhook: bash not found\")\n+                return False, None\n+            argv = [bash, str(path)]\n+        else:\n+            argv = [sys.executable, str(path)]\n+\n+        try:\n+            from tools.environments.local import _sanitize_subprocess_env\n+\n+            popen_kwargs = {\"creationflags\": 0x08000000} if sys.platform == \"win32\" else {}\n+            result = subprocess.run(\n+                argv,\n+                input=json.dumps(payload),\n+                capture_output=True,\n+                text=True,\n+                timeout=self.script_timeout_seconds,\n+                cwd=str(path.parent),\n+                env=_sanitize_subprocess_env(os.environ.copy()),\n+                **popen_kwargs,\n+            )\n+        except subprocess.TimeoutExpired:\n+            logger.warning(\"[webhook] script timed out: %s\", path)\n+            return False, None\n+        except Exception as exc:\n+            logger.warning(\"[webhook] script execution failed: %s\", exc)\n+            return False, None\n+\n+        stdout = (result.stdout or \"\").strip()\n+        stderr = (result.stderr or \"\").strip()\n+        try:\n+            from agent.redact import redact_sensitive_text\n+\n+            stdout = redact_sensitive_text(stdout)\n+            stderr = redact_sensitive_text(stderr)\n+        except Exception as exc:\n+            logger.warning(\"[webhook] Failed to redact script output: %s\", exc)\n+            stdout = \"[REDACTED - redaction failed]\"\n+            stderr = \"[REDACTED - redaction failed]\"\n+        if result.returncode != 0:\n+            logger.info(\n+                \"[webhook] script ignored webhook path=%s code=%s stderr=%s\",\n+                path.name,\n+                result.returncode,\n+                stderr[:200],\n+            )\n+            return False, None\n+        if not stdout or stdout == \"[SILENT]\":\n+            return False, None\n+\n+        try:\n+            transformed = json.loads(stdout)\n+        except json.JSONDecodeError:\n+            transformed = {**payload, \"script_output\": stdout}\n+        if not isinstance(transformed, dict):\n+            logger.warning(\"[webhook] script stdout must be a JSON object or text\")\n+            return False, None\n+        if (\n+            transformed.get(\"[SILENT]\") is True\n+            or transformed.get(\"__hermes_ignore__\") is True\n+        ):\n+            return False, None\n+        return True, transformed"]},{"file":"hermes_cli/subcommands/webhook.py","hunks":["@@ -55,6 +55,13 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:\n         \"message. Zero LLM cost. Requires --deliver to be a real target \"\n         \"(not 'log').\",\n     )\n+    wh_sub.add_argument(\n+        \"--script\",\n+        default=\"\",\n+        help=\"Filter/transform script under ~/.hermes/scripts/. The route \"\n+        \"payload is passed as JSON on stdin; empty stdout, [SILENT], or a \"\n+        \"nonzero exit code ignores the webhook.\",\n+    )\n \n     webhook_subparsers.add_parser(\n         \"list\", aliases=[\"ls\"], help=\"List all dynamic subscriptions\""]},{"file":"hermes_cli/web_server.py","hunks":["@@ -10932,6 +10932,7 @@ class WebhookCreate(BaseModel):\n     description: Optional[str] = None\n     events: List[str] = []\n     prompt: Optional[str] = None\n+    script: Optional[str] = None\n     skills: List[str] = []\n     deliver: str = \"log\"\n     deliver_only: bool = False","@@ -10948,6 +10949,7 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D\n         \"deliver\": route.get(\"deliver\", \"log\"),\n         \"deliver_only\": bool(route.get(\"deliver_only\")),\n         \"prompt\": route.get(\"prompt\", \"\"),\n+        \"script\": route.get(\"script\", \"\"),\n         \"skills\": list(route.get(\"skills\") or []),\n         \"created_at\": route.get(\"created_at\"),\n         \"url\": f\"{base_url}/webhooks/{name}\",","@@ -11031,6 +11033,8 @@ async def create_webhook(body: WebhookCreate):\n         \"deliver\": body.deliver or \"log\",\n         \"created_at\": _time.strftime(\"%Y-%m-%dT%H:%M:%SZ\", _time.gmtime()),\n     }\n+    if body.script and body.script.strip():\n+        route[\"script\"] = body.script.strip()\n     if body.deliver_only:\n         route[\"deliver_only\"] = True\n     if body.deliver_chat_id:"]},{"file":"hermes_cli/webhook.py","hunks":["@@ -189,6 +189,10 @@ def _cmd_subscribe(args):\n             return\n         route[\"deliver_only\"] = True\n \n+    script = getattr(args, \"script\", \"\") or \"\"\n+    if script.strip():\n+        route[\"script\"] = script.strip()\n+\n     if args.deliver_chat_id:\n         route[\"deliver_extra\"] = {\"chat_id\": args.deliver_chat_id}\n ","@@ -212,6 +216,8 @@ def _cmd_subscribe(args):\n         prompt_preview = route[\"prompt\"][:80] + (\"...\" if len(route[\"prompt\"]) > 80 else \"\")\n         label = \"Message\" if route.get(\"deliver_only\") else \"Prompt\"\n         print(f\"  {label}: {prompt_preview}\")\n+    if route.get(\"script\"):\n+        print(f\"  Script: {route['script']}\")\n     print(\"\\n  Configure your service to POST to the URL above.\")\n     print(\"  Use the secret for HMAC-SHA256 signature validation.\")\n     print(\"  The gateway must be running to receive events (hermes gateway run).\\n\")","@@ -238,6 +244,8 @@ def _cmd_list(args):\n         print(f\"    URL:     {base_url}/webhooks/{name}\")\n         print(f\"    Events:  {events}\")\n         print(f\"    Deliver: {deliver}\")\n+        if route.get(\"script\"):\n+            print(f\"    Script:  {route['script']}\")\n         print()\n \n "]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"evelynburger\",  # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity)\n     \"[redacted:email]\": \"SiteupAgencia\",  # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578)\n     \"164521089+rainbowgits@users.noreply.github.com\": \"rainbowgore\",  # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349)\n     \"[redacted:email]\": \"thestudionorth\",  # PR #60015 salvage (mcp: parent-death watchdog for stdio children; commit under unlinked local identity)"]},{"file":"tests/gateway/test_webhook_adapter.py","hunks":["@@ -608,6 +608,285 @@ async def test_event_filter_accepts_payload_type_field(self):\n             assert resp.status == 202\n \n \n+# ===================================================================\n+# Payload filters\n+# ===================================================================\n+\n+\n+class TestPayloadFilters:\n+    \"\"\"Tests for route-level payload filters in _handle_webhook.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_filter_rejects_before_agent_dispatch(self):\n+        \"\"\"A non-matching filter returns ignored and never starts the agent.\"\"\"\n+        routes = {\n+            \"todoist\": {\n+                \"secret\": [redacted:secret-assignment]\n+                \"filters\": [{\"field\": \"payload.label\", \"equals\": \"urgent\"}],\n+                \"prompt\": \"Task: {payload.content}\",\n+            }\n+        }\n+        adapter = _make_adapter(routes=routes)\n+        adapter.handle_message = AsyncMock()\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                \"/webhooks/todoist\",\n+                json={\"payload\": {\"label\": \"later\", \"content\": \"Buy milk\"}},\n+                headers={\"X-GitHub-Delivery\": \"filter-skip-1\"},\n+            )\n+            assert resp.status == 200\n+            data = await resp.json()\n+            assert data == {\n+                \"status\": \"ignored\",\n+                \"reason\": \"filter\",\n+                \"route\": \"todoist\",\n+            }\n+\n+        adapter.handle_message.assert_not_called()\n+        assert \"filter-skip-1\" not in adapter._seen_deliveries\n+\n+    @pytest.mark.asyncio\n+    async def test_filter_accepts_nested_any_and_in_file(self, tmp_path, monkeypatch):\n+        \"\"\"Nested any groups can match dynamic watchlists under HERMES_HOME.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        watchlist = tmp_path / \"data\" / \"watchlist.json\"\n+        watchlist.parent.mkdir()\n+        watchlist.write_text(json.dumps([\"chat-1\", \"chat-2\"]), encoding=\"utf-8\")\n+        routes = {\n+            \"waha\": {\n+                \"secret\": [redacted:secret-assignment]\n+                \"filters\": [\n+                    {\"field\": \"payload.fromMe\", \"equals\": False},\n+                    {\n+                        \"any\": [\n+                            {\n+                                \"field\": \"payload.chatId\",\n+                                \"in_file\": \"~/.hermes/data/watchlist.json\",\n+                            },\n+                            {\n+                                \"field\": \"payload.id.remote\",\n+                                \"in_file\": \"~/.hermes/data/watchlist.json\",\n+                            },\n+                        ]\n+                    },\n+                ],\n+                \"prompt\": \"Message from {payload.chatId}: {payload.body}\",\n+            }\n+        }\n+        adapter = _make_adapter(routes=routes)\n+        captured = []\n+\n+        async def _capture(event):\n+            captured.append(event)\n+\n+        adapter.handle_message = _capture\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                \"/webhooks/waha\",\n+                json={\n+                    \"payload\": {\n+                        \"fromMe\": False,\n+                        \"chatId\": \"chat-2\",\n+                        \"body\": \"hello\",\n+                    }\n+                },\n+                headers={\"X-GitHub-Delivery\": \"filter-match-1\"},\n+            )\n+            assert resp.status == 202\n+\n+        await asyncio.sleep(0.05)\n+        assert len(captured) == 1\n+        assert captured[0].text == \"Message from chat-2: hello\"\n+\n+    @pytest.mark.asyncio\n+    async def test_filter_applies_to_deliver_only_before_delivery(self):\n+        \"\"\"Filtered direct-delivery routes skip target delivery too.\"\"\"\n+        routes = {\n+            \"alerts\": {\n+                \"secret\": [redacted:secret-assignment]\n+                \"deliver\": \"telegram\",\n+                \"deliver_only\": True,\n+                \"deliver_extra\": {\"chat_id\": \"123\"},\n+                \"filters\": [{\"field\": \"severity\", \"in\": [\"critical\"]}],\n+                \"prompt\": \"Alert: {message}\",\n+            }\n+        }\n+        adapter = _make_adapter(routes=routes)\n+        mock_target = AsyncMock()\n+        mock_target.send = AsyncMock(return_value=SendResult(success=True))\n+        mock_runner = MagicMock()\n+        mock_runner.adapters = {Platform.TELEGRAM: mock_target}\n+        adapter.gateway_runner = mock_runner\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                \"/webhooks/alerts\",\n+                json={\"severity\": \"info\", \"message\": \"noise\"},\n+                headers={\"X-GitHub-Delivery\": \"filter-direct-1\"},\n+            )\n+            assert resp.status == 200\n+            assert (await resp.json())[\"reason\"] == \"filter\"\n+\n+        mock_target.send.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_script_transforms_payload_before_prompt_rendering(self, tmp_path, monkeypatch):\n+        \"\"\"A script can replace the payload used by prompt templates.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        scripts = tmp_path / \"scripts\"\n+        scripts.mkdir()\n+        script = scripts / \"todoist_filter.py\"\n+        script.write_text(\n+            \"import json, sys\\n\"\n+            \"payload = json.load(sys.stdin)\\n\"\n+            \"payload['body'] = payload['task']['content'].upper()\\n\"\n+            \"print(json.dumps(payload))\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        routes = {\n+            \"todoist\": {\n+                \"secret\": [redacted:secret-assignment]\n+                \"script\": \"todoist_filter.py\",\n+                \"prompt\": \"Task: {body}\",\n+            }\n+        }\n+        adapter = _make_adapter(routes=routes)\n+        captured = []\n+\n+        async def _capture(event):\n+            captured.append(event)\n+\n+        adapter.handle_message = _capture\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                \"/webhooks/todoist\",\n+                json={\"task\": {\"content\": \"pay bills\"}},\n+                headers={\"X-GitHub-Delivery\": \"script-transform-1\"},\n+            )\n+            assert resp.status == 202\n+\n+        await asyncio.sleep(0.05)\n+        assert captured[0].text == \"Task: PAY BILLS\"\n+        assert captured[0].raw_message[\"body\"] == \"PAY BILLS\"\n+\n+    @pytest.mark.asyncio\n+    async def test_script_tilde_hermes_path_resolves_to_active_profile_home(self, tmp_path, monkeypatch):\n+        \"\"\"~/.hermes/scripts paths must resolve through HERMES_HOME for profiles.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        scripts = tmp_path / \"scripts\"\n+        scripts.mkdir()\n+        (scripts / \"todoist_filter.py\").write_text(\n+            \"import json, sys\\n\"\n+            \"payload = json.load(sys.stdin)\\n\"\n+            \"payload['body'] = 'profile-safe'\\n\"\n+            \"print(json.dumps(payload))\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        routes = {\n+            \"todoist\": {\n+                \"secret\": [redacted:secret-assignment]\n+                \"script\": \"~/.hermes/scripts/todoist_filter.py\",\n+                \"prompt\": \"Task: {body}\",\n+            }\n+        }\n+        adapter = _make_adapter(routes=routes)\n+        captured = []\n+\n+        async def _capture(event):\n+            captured.append(event)\n+\n+        adapter.handle_message = _capture\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                \"/webhooks/todoist\",\n+                json={\"task\": {\"content\": \"pay bills\"}},\n+                headers={\"X-GitHub-Delivery\": \"script-profile-path-1\"},\n+            )\n+            assert resp.status == 202\n+\n+        await asyncio.sleep(0.05)\n+        assert captured[0].text == \"Task: profile-safe\"\n+\n+    @pytest.mark.asyncio\n+    async def test_script_silent_stdout_ignores_without_idempotency_hit(self, tmp_path, monkeypatch):\n+        \"\"\"Empty or [SILENT] script stdout filters the webhook out.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        scripts = tmp_path / \"scripts\"\n+        scripts.mkdir()\n+        (scripts / \"skip.py\").write_text(\"print('[SILENT]')\\n\", encoding=\"utf-8\")\n+        routes = {\n+            \"todoist\": {\n+                \"secret\": [redacted:secret-assignment]\n+                \"script\": \"skip.py\",\n+                \"prompt\": \"Task: {body}\",\n+            }\n+        }\n+        adapter = _make_adapter(routes=routes)\n+        adapter.handle_message = AsyncMock()\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                \"/webhooks/todoist\",\n+                json={\"body\": \"ignore me\"},\n+                headers={\"X-GitHub-Delivery\": \"script-silent-1\"},\n+            )\n+            assert resp.status == 200\n+            data = await resp.json()\n+            assert data == {\n+                \"status\": \"ignored\",\n+                \"reason\": \"script\",\n+                \"route\": \"todoist\",\n+            }\n+\n+        adapter.handle_message.assert_not_called()\n+        assert \"script-silent-1\" not in adapter._seen_deliveries\n+\n+    @pytest.mark.asyncio\n+    async def test_script_nonzero_exit_ignores_webhook(self, tmp_path, monkeypatch):\n+        \"\"\"A script can fail closed by exiting nonzero.\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        scripts = tmp_path / \"scripts\"\n+        scripts.mkdir()\n+        (scripts / \"skip.py\").write_text(\n+            \"import sys\\nsys.exit(2)\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        routes = {\n+            \"todoist\": {\n+                \"secret\": [redacted:secret-assignment]\n+                \"script\": \"skip.py\",\n+                \"prompt\": \"Task: {body}\",\n+            }\n+        }\n+        adapter = _make_adapter(routes=routes)\n+        adapter.handle_message = AsyncMock()\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                \"/webhooks/todoist\",\n+                json={\"body\": \"ignore me\"},\n+                headers={\"X-GitHub-Delivery\": \"script-nonzero-1\"},\n+            )\n+            assert resp.status == 200\n+            data = await resp.json()\n+            assert data[\"status\"] == \"ignored\"\n+            assert data[\"reason\"] == \"script\"\n+\n+        adapter.handle_message.assert_not_called()\n+        assert \"script-nonzero-1\" not in adapter._seen_deliveries\n+\n+\n # ===================================================================\n # HTTP handling\n # ===================================================================","@@ -1246,4 +1525,3 @@ async def test_connect_allows_real_secret_on_public_bind(self):\n             assert result is True\n         finally:\n             await adapter.disconnect()\n-"]},{"file":"tests/hermes_cli/test_dashboard_admin_endpoints.py","hunks":["@@ -223,6 +223,30 @@ def test_list_disabled_and_create_blocked(self):\n         r = self.client.post(\"/api/webhooks\", json={\"name\": \"gh\", \"deliver\": \"log\"})\n         assert r.status_code == 400\n \n+    def test_create_webhook_persists_script(self):\n+        from hermes_cli.config import load_config, save_config\n+\n+        cfg = load_config()\n+        cfg.setdefault(\"platforms\", {})[\"webhook\"] = {\n+            \"enabled\": True,\n+            \"extra\": {\"host\": \"0.0.0.0\", \"port\": 8644},\n+        }\n+        save_config(cfg)\n+\n+        r = self.client.post(\n+            \"/api/webhooks\",\n+            json={\n+                \"name\": \"todoist\",\n+                \"deliver\": \"log\",\n+                \"script\": \"todoist_filter.py\",\n+            },\n+        )\n+        assert r.status_code == 200\n+        assert r.json()[\"script\"] == \"todoist_filter.py\"\n+\n+        subs = self.client.get(\"/api/webhooks\").json()[\"subscriptions\"]\n+        assert subs[0][\"script\"] == \"todoist_filter.py\"\n+\n     def test_enable_platform_starts_gateway_restart(self, monkeypatch):\n         import hermes_cli.web_server as ws\n         from hermes_cli.config import load_config"]},{"file":"tests/hermes_cli/test_webhook_cli.py","hunks":["@@ -35,6 +35,7 @@ def _make_args(**kwargs):\n         \"deliver_chat_id\": \"\",\n         \"secret\": \"\",\n         \"payload\": \"\",\n+        \"script\": \"\",\n     }\n     defaults.update(kwargs)\n     return Namespace(**defaults)","@@ -72,6 +73,12 @@ def test_custom_secret(self):\n         ))\n         assert _load_subscriptions()[\"s\"][\"secret\"] == \"my-secret\"\n \n+    def test_script_option_is_persisted(self):\n+        webhook_command(_make_args(\n+            webhook_action=\"subscribe\", name=\"s\", script=\"todoist_filter.py\"\n+        ))\n+        assert _load_subscriptions()[\"s\"][\"script\"] == \"todoist_filter.py\"\n+\n     def test_auto_secret(self):\n         webhook_command(_make_args(webhook_action=\"subscribe\", name=\"s\"))\n         secret = _load_subscriptions()[\"s\"][\"secret\"]"]},{"file":"website/docs/guides/webhook-github-pr-review.md","hunks":["@@ -182,12 +182,20 @@ tail -f \"${HERMES_HOME:-$HOME/.hermes}/logs/gateway.log\"\n \n ## Filtering to specific actions\n \n-GitHub sends `pull_request` events for many actions: `opened`, `synchronize`, `reopened`, `closed`, `labeled`, etc. The `events` list filters only by the `X-GitHub-Event` header value — it cannot filter by action sub-type at the routing level.\n+GitHub sends `pull_request` events for many actions: `opened`, `synchronize`, `reopened`, `closed`, `labeled`, etc. The `events` list filters by the `X-GitHub-Event` header value, and route-level `filters` can narrow by payload fields such as `action`.\n \n The prompt in Step 1 already handles this by instructing the agent to stop early for `closed` and `labeled` events.\n \n :::warning The agent still runs and consumes tokens\n-The \"stop here\" instruction prevents a meaningful review, but the agent still runs to completion for every `pull_request` event regardless of action. GitHub webhooks can only filter by event type (`pull_request`, `push`, `issues`, etc.) — not by action sub-type (`opened`, `closed`, `labeled`). There is no routing-level filter for sub-actions. For high-volume repos, accept this cost or filter upstream with a GitHub Actions workflow that calls your webhook URL conditionally.\n+The \"stop here\" instruction prevents a meaningful review, but the agent still runs to completion for every `pull_request` event regardless of action. Prefer filtering before the agent wakes:\n+\n+```yaml\n+filters:\n+  - field: \"action\"\n+    in: [\"opened\", \"synchronize\", \"reopened\"]\n+```\n+\n+For high-volume repositories, you can still filter upstream with a GitHub Actions workflow that calls your webhook URL conditionally.\n :::\n \n > There is no Jinja2 or conditional template syntax. `{field}` and `{nested.field}` are the only substitutions supported. Anything else is passed verbatim to the agent."]},{"file":"website/docs/user-guide/messaging/webhooks.md","hunks":["@@ -81,6 +81,8 @@ Routes define how different webhook sources are handled. Each route is a named e\n | `events` | No | List of event types to accept (e.g. `[\"pull_request\"]`). If empty, all events are accepted. Event type is read from `X-GitHub-Event`, `X-GitLab-Event`, or `event_type` in the payload. |\n | `secret` | **Yes** | HMAC secret for signature validation. Falls back to the global `secret` if not set on the route. Set to `\"INSECURE_NO_AUTH\"` for testing only (skips validation). |\n | `prompt` | No | Template string with dot-notation payload access (e.g. `{pull_request.title}`). If omitted, the full JSON payload is dumped into the prompt. Payload fields are untrusted — see [Authenticated does not mean trusted](#authenticated-does-not-mean-trusted). |\n+| `filters` | No | Declarative payload filters evaluated after auth/body/event filtering and before agent or direct delivery work. Non-matches return `{\"status\":\"ignored\",\"reason\":\"filter\"}` with HTTP 200. |\n+| `script` | No | Filter/transform script under `~/.hermes/scripts/`. The webhook payload is passed as JSON on stdin. JSON object stdout replaces the payload before templating; text stdout is exposed as `script_output`; empty stdout, `[SILENT]`, or a nonzero exit code ignores the webhook. |\n | `skills` | No | List of skill names to load for the agent run. |\n | `deliver` | No | Where to send the response: `github_comment`, `telegram`, `discord`, `slack`, `signal`, `sms`, `whatsapp`, `matrix`, `mattermost`, `homeassistant`, `email`, `dingtalk`, `feishu`, `wecom`, `weixin`, `bluebubbles`, `qqbot`, or `log` (default). |\n | `deliver_extra` | No | Additional delivery config — keys depend on `deliver` type (e.g. `repo`, `pr_number`, `chat_id`). Values support the same `{dot.notation}` templates as `prompt`. |","@@ -116,9 +118,75 @@ platforms:\n           events: [\"push\"]\n           secret: \"[redacted:secret-assignment]\"\n           prompt: \"New push to {repository.full_name} branch {ref}: {head_commit.message}\"\n+          filters:\n+            - field: \"ref\"\n+              equals: \"refs/heads/main\"\n           deliver: \"telegram\"\n ```\n \n+### Payload Filters\n+\n+Use `filters` when a provider sends a broad event stream but only some payloads should wake the agent or trigger `deliver_only` delivery. Filters run after signature validation, body parsing, and `events`, but before prompt rendering, idempotency, agent dispatch, or direct delivery.\n+\n+```yaml\n+platforms:\n+  webhook:\n+    extra:\n+      routes:\n+        todoist:\n+          events: [\"item:updated\"]\n+          secret: \"[redacted:secret-assignment]\"\n+          filters:\n+            - field: \"payload.labels\"\n+              contains: \"hermes\"\n+            - any:\n+                - field: \"payload.priority\"\n+                  equals: 4\n+                - field: \"payload.project_id\"\n+                  in_file: \"~/.hermes/data/todoist/watchlist.json\"\n+          prompt: \"Todoist task changed: {payload.content}\"\n+```\n+\n+Supported operators:\n+\n+- `exists: true|false`\n+- `missing: true`\n+- `equals` / `not_equals`\n+- `contains` for strings, lists, and dict keys\n+- `in` for inline lists\n+- `in_file` for JSON arrays, JSON objects (keys are used), or newline-delimited text files\n+- `regex`\n+- `all`, `any`, and `not` groups\n+\n+Field paths use dot notation. `payload.foo` reads from a top-level `payload` object when one exists, or from the root webhook body for flat payloads. `event` / `event_type` match the resolved event type, and `headers.<Name>` reads request headers.\n+\n+### Script Filters and Transforms\n+\n+Use `script` when declarative filters are not enough. Scripts must live under `~/.hermes/scripts/` for the active profile; relative paths resolve there, and path traversal outside that directory is blocked. `.sh` and `.bash` scripts run with bash, and all other extensions run with the current Python interpreter.\n+\n+The route payload is sent to stdin as JSON:\n+\n+```python\n+# ~/.hermes/scripts/todoist-hermes-label.py\n+import json\n+import sys\n+\n+payload = json.load(sys.stdin)\n+labels = payload.get(\"payload\", {}).get(\"labels\", [])\n+if \"hermes\" not in labels:\n+    print(\"[SILENT]\")\n+    raise SystemExit(0)\n+\n+payload[\"body\"] = payload[\"payload\"][\"content\"]\n+print(json.dumps(payload))\n+```\n+\n+Script outcomes:\n+\n+- JSON object stdout replaces the payload used by `prompt` and `deliver_extra`.\n+- Non-JSON text stdout is added to the payload as `script_output`.\n+- Empty stdout, exact `[SILENT]`, `{\"__hermes_ignore__\": true}`, timeout, missing script, or nonzero exit code returns HTTP 200 with `{\"status\":\"ignored\",\"reason\":\"script\"}`.\n+\n ### Prompt Templates\n \n Prompts use dot-notation to access nested fields in the webhook payload:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}