# NousResearch/hermes-agent · Webhook payload filtering and route scripting feature

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #60944. Verify it addresses your case.
- **Recommended:** read PR #60944 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #10572: \[Feature\]: Support payload filtering or script param in webhooks

## Prior & competing PRs

- **#60944: feat(gateway): webhook payload filters and route scripts (salvage #57544)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `cli-config.yaml.example`

    ```diff
    @@ -846,6 +846,11 @@ platform_toolsets:
     #         priority_mode: prepend
     #         priority:
     #           - my_plugin_command
    +#   webhook:
    +#     extra:
    +#       # Route scripts default to a 30 second timeout. Scripts must live under
    +#       # the active profile's scripts directory and receive webhook JSON on stdin.
    +#       script_timeout_seconds: 30
     #
     # Discord-specific settings (config.yaml top-level, not under platforms:):
     #
    ```
  - `gateway/platforms/webhook.py`

    ```diff
    @@ -58,6 +58,10 @@
         MessageType,
         SendResult,
     )
    +from gateway.platforms.webhook_filters import (
    +    DEFAULT_SCRIPT_TIMEOUT_SECONDS,
    +    WebhookRouteProcessor,
    +)
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -78,7 +82,6 @@
     _INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
     _DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
     _RATE_WINDOW_SECONDS = 60.0
    -
     # Hostnames/IP literals that only serve connections originating on the same
     # machine. Anything else is treated as a public bind for safety-rail purposes.
     _LOOPBACK_HOSTS = frozenset({
    ```

    ```diff
    @@ -155,6 +158,15 @@ def __init__(self, config: PlatformConfig):
             self._max_body_bytes: int = int(
                 config.extra.get("max_body_bytes", 1_048_576)
             )  # 1MB
    +        self._script_timeout_seconds: int = int(
    +            config.extra.get(
    +                "script_timeout_seconds",
    +                DEFAULT_SCRIPT_TIMEOUT_SECONDS,
    +            )
    +        )
    +        self._route_processor = WebhookRouteProcessor(
    +            script_timeout_seconds=self._script_timeout_seconds
    +        )
     
         # ------------------------------------------------------------------
         # Lifecycle
    ```

    ```diff
    @@ -571,6 +583,45 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response":
                     {"status": "ignored", "event": event_type}
                 )
     
    +        if not self._route_processor.route_filters_match(
    +            route_config, payload, event_type, request.headers
    +        ):
    +            logger.info(
    +                "[webhook] filtered event=%s route=%s",
    +                event_type,
    +                route_name,
    +            )
    +            return web.json_response(
    +                {
    +                    "status": "ignored",
    +                    "reason": "filter",
    +                    "route": route_name,
    +                }
    +            )
    +
    +        if route_config.get("script"):
    +            # run_route_script shells out (subprocess.run, up to its timeout);
    +            # run it in a worker thread so it can't block the gateway event loop.
    +            keep, transformed_payload = await asyncio.to_thread(
    +                self._route_processor.run_route_script,
    +                route_config.get("script"),
    +                payload,
    +            )
    +            if not keep:
    +                logger.info(
    +                    "[webhook] script ignored event=%s route=%s",
    +                    event_type,
    +                    route_name,
    +                )
    +                return web.json_response(
    +                    {
    +                        "status": "ignored",
    +                        "reason": "script",
    +                        "route": route_name,
    +                    }
    +                )
    +            payload = transformed_payload or payload
    +
             # Format prompt from template
             prompt_template = route_config.get("prompt", "")
             prompt = self._render_prompt(
    ```
  - `gateway/platforms/webhook_filters.py`

    ```diff
    @@ -0,0 +1,302 @@
    +"""Route-local filters and script transforms for the webhook adapter."""
    +
    +from __future__ import annotations
    +
    +import json
    +import logging
    +import os
    +import re
    +import shutil
    +import subprocess
    +import sys
    +from pathlib import Path
    +from typing import Any, Optional
    +
    +logger = logging.getLogger(__name__)
    +
    +DEFAULT_SCRIPT_TIMEOUT_SECONDS = 30
    +_MISSING = object()
    +
    +
    +def _stringify_filter_value(value: Any) -> str:
    +    if value is _MISSING:
    +        return ""
    +    if isinstance(value, (dict, list)):
    +        return json.dumps(value, sort_keys=True)
    +    return str(value)
    +
    +
    +def _resolve_profile_path(path_value: Any) -> Optional[Path]:
    +    """Resolve a user path, mapping ~/.hermes to the active profile home."""
    +    if not isinstance(path_value, str):
    +        return None
    +    raw = os.path.expandvars(path_value.strip())
    +    if not raw:
    +        return None
    +    from hermes_constants import get_hermes_home
    +
    +    hermes_home = get_hermes_home()
    +    if raw == "~/.hermes":
    +        return hermes_home
    +    if raw.startswith("~/.hermes/"):
    +        return hermes_home / raw.removeprefix("~/.hermes/")
    +    path = Path(raw).expanduser()
    +    if path.is_absolute():
    +        return path
    +    return hermes_home / path
    +
    +
    +def _resolve_script_path(script_value: Any) -> tuple[Optional[Path], Optional[str]]:
    +    """Resolve a route script under HERMES_HOME/scripts."""
    +    if not isinstance(script_value, str) or not script_value.strip():
    +        return None, "script path is empty"
    +    from hermes_constants import get_hermes_home
    +
    +    scripts_root = (get_hermes_home() / "scripts").resolve()
    +    raw_text = os.path.expandvars(script_value.strip())
    +    if raw_text == "~/.hermes" or raw_text.startswith("~/.hermes/"):
    +        mapped = _resolve_profile_path(raw_text)
    +        candidate = mapped.resolve() if mapped is not None else scripts_root
    +    else:
    +        raw = Path(raw_text).expanduser()
    +        candidate = raw.resolve() if raw.is_absolute() else (scripts_root / raw).resolve()
    +    try:
    +        candidate.relative_to(scripts_root)
    +    except ValueError:
    +        return None, f"script path resolves outside {scripts_root}"
    +    if not candidate.exists():
    +        return None, f"script not found: {candidate}"
    +    if not candidate.is_file():
    +        return None, f"script path is not a file: {candidate}"
    +    return candidate, None
    +
    +
    +def _load_filter_file_values(path_value: Any) -> list[Any]:
    +    path = _resolve_profile_path(path_value)
    +    if path is None:
    +        return []
    +    try:
    +        raw = path.read_text(encoding="utf-8")
    +    except OSError as exc:
    +        logger.warning("[webhook] filter in_file read failed for %s: %s", path, exc)
    +        return []
    +    try:
    +        data = json.loads(raw)
    +    except json.JSONDecodeError:
    +        return [line.strip() for line in raw.splitlines() if line.strip()]
    +    if isinstance(data, list):
    +        return data
    +    if isinstance(data, dict):
    +        return list(data.keys())
    +    return [data]
    +
    +
    +class WebhookRouteProcessor:
    +    """Evaluate declarative filters and optional script transforms."""
    +
    +    def __init__(
    +        self,
    +        *,
    +        script_timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS,
    +    ) -> None:
    +        self.script_timeout_seconds = max(1, int(script_timeout_seconds))
    +
    +    def resolve_filter_field(
    +        self,
    +        field: Any,
    +        payload: dict,
    +        event_type: str,
    +        headers: Any,
    +    ) -> Any:
    +        """Resolve a dotted filter field against payload/event/headers context."""
    +        if not isinstance(field, str) or not field.strip():
    +            return _MISSING
    +        parts = [part for part in field.strip().split(".") if part]
    +        if not parts:
    +            return _MISSING
    +        header_dict = dict(headers or {})
    +        context = {
    +            "payload": payload.get("payload", payload),
    +            "event": event_type,
    +            "event_type": event_type,
    +            "headers": header_dict,
    +        }
    +        if parts[0] in context:
    +            value: Any = context[parts[0]]
    +            parts = parts[1:]
    +        else:
    +            value = payload
    +        for part in parts:
    +            if isinstance(value, dict):
    +                value = value.get(part, _MISSING)
    +            elif isinstance(value, list) and part.isdigit():
    +                idx = int(part)
    +                value = value[idx] if 0 <= idx < len(value) else _MISSING
    +            else:
    +                return _MISSING
    +            if value is _MISSING:
    +                return _MISSING
    +        return value
    +
    +    def filter_matches(
    +        self,
    +        spec: Any,
    +        payload: dict,
    +        event_type: str,
    +        headers: Any,
    +    ) -> bool:
    +        """Evaluate one declarative webhook filter spec."""
    +        if not isinstance(spec, dict):
    +            logger.warning("[webhook] Ignoring invalid filter spec: %r", spec)
    +            return False
    +
    +        if "all" in spec:
    +            items = spec.get("all")
    +            return isinstance(items, list) and all(
    +                self.filter_matches(item, payload, event_type, headers)
    +                for item in items
    +            )
    +        if "any" in spec:
    +            items = spec.get("any")
    +            return isinstance(items, list) and any(
    +                self.filter_matches(item, payload, event_type, headers)
    +                for item in items
    +            )
    +        if "not" in spec:
    +            return not self.filter_matches(spec.get("not"), payload, event_type, headers)
    +
    +        value = self.resolve_filter_field(
    +            spec.get("field"), payload, event_type, headers
    +        )
    +
    +        if "exists" in spec:
    +            exists = value is not _MISSING
    +            return exists is bool(spec.get("exists"))
    +        if spec.get("missing") is True:
    +            return value is _MISSING
    +        if "equals" in spec:
    +            return value is not _MISSING and value == spec.get("equals")
    +        if "not_equals" in spec:
    +            return value is _MISSING or value != spec.get("not_equals")
    +        if "contains" in spec:
    +            needle = spec.get("contains")
    +            if value is _MISSING:
    +                return False
    +            if isinstance(value, (list, tuple, set, dict)):
    +                return needle in value
    +            return str(needle) in _stringify_filter_value(value)
    +        if "in" in spec:
    +            haystack = spec.get("in")
    +            return isinstance(haystack, list) and value in haystack
    +        if "in_file" in spec:
    +            return value in _load_filter_file_values(spec.get("in_file"))
    +        if "regex" in spec:
    +            if value is _MISSING:
    +                return False
    +            try:
    +                return (
    +                    re.search(str(spec.get("regex")), _stringify_filter_value(value))
    +                    is not None
    +                )
    +            except re.error as exc:
    +                logger.warning("[webhook] Invalid webhook filter regex: %s", exc)
    +                return False
    +
    +        logger.warning("[webhook] Filter spec has no supported operator: %r", spec)
    +        return False
    +
    +    def route_filters_match(
    +        self,
    +        route_config: dict,
    +        payload: dict,
    +        event_type: str,
    +        headers: Any,
    +    ) -> bool:
    +        filters = route_config.get("filters") or []
    +        if not filters:
    +            return True
    +        if isinstance(filters, dict):
    +            return self.filter_matches(filters, payload, event_type, headers)
    +        if not isinstance(filters, list):
    +            logger.warning("[webhook] filters must be a list or object")
    +            return False
    +        return all(
    +            self.filter_matches(spec, payload, event_type, headers)
    +            for spec in filters
    +        )
    +
    +    def run_route_script(self, script_value: Any, payload: dict) -> tuple[bool, Optional[dict]]:
    +        """Run a route script and return (should_continue, transformed_payload)."""
    +        path, error = _resolve_script_path(script_value)
    +        if error or path is None:
    +            logger.warning("[webhook] script ignored webhook: %s", error)
    +            return False, None
    +
    +        suffix = path.suffix.lower()
    +        if suffix in {".sh", ".bash"}:
    +            bash = shutil.which("bash") or (
    +                "/bin/bash" if os.path.isfile("/bin/bash") else None
    +            )
    +            if bash is None:
    +                logger.warning("[webhook] script ignored webhook: bash not found")
    +                return False, None
    +            argv = [bash, str(path)]
    +        else:
    +            argv = [sys.executable, str(path)]
    +
    +        try:
    +            from tools.environments.local import _sanitize_subprocess_env
    +
    +            popen_kwargs = {"creationflags": 0x08000000} if sys.platform == "win32" else {}
    +            result = subprocess.run(
    +                argv,
    +                input=json.dumps(payload),
    +                capture_output=True,
    +                text=True,
    +                timeout=self.script_timeout_seconds,
    +                cwd=str(path.parent),
    +                env=_sanitize_subprocess_env(os.environ.copy()),
    +                **popen_kwargs,
    +            )
    +        except subprocess.TimeoutExpired:
    +            logger.warning("[webhook] script timed out: %s", path)
    +            return False, None
    +        except Exception as exc:
    +            logger.warning("[webhook] script execution failed: %s", exc)
    +            return False, None
    +
    +        stdout = (result.stdout or "").strip()
    +        stderr = (result.stderr or "").strip()
    +        try:
    +            from agent.redact import redact_sensitive_text
    +
    +            stdout = redact_sensitive_text(stdout)
    +            stderr = redact_sensitive_text(stderr)
    +        except Exception as exc:
    +            logger.warning("[webhook] Failed to redact script output: %s", exc)
    +            stdout = "[REDACTED - redaction failed]"
    +            stderr = "[REDACTED - redaction failed]"
    +        if result.returncode != 0:
    +            logger.info(
    +                "[webhook] script ignored webhook path=%s code=%s stderr=%s",
    +                path.name,
    +                result.returncode,
    +                stderr[:200],
    +            )
    +            return False, None
    +        if not stdout or stdout == "[SILENT]":
    +            return False, None
    +
    +        try:
    +            transformed = json.loads(stdout)
    +        except json.JSONDecodeError:
    +            transformed = {**payload, "script_output": stdout}
    +        if not isinstance(transformed, dict):
    +            logger.warning("[webhook] script stdout must be a JSON object or text")
    +            return False, None
    +        if (
    +            transformed.get("[SILENT]") is True
    +            or transformed.get("__hermes_ignore__") is True
    +        ):
    +            return False, None
    +        return True, transformed
    ```
  - `hermes_cli/subcommands/webhook.py`

    ```diff
    @@ -55,6 +55,13 @@ def build_webhook_parser(subparsers, *, cmd_webhook: Callable) -> None:
             "message. Zero LLM cost. Requires --deliver to be a real target "
             "(not 'log').",
         )
    +    wh_sub.add_argument(
    +        "--script",
    +        default="",
    +        help="Filter/transform script under ~/.hermes/scripts/. The route "
    +        "payload is passed as JSON on stdin; empty stdout, [SILENT], or a "
    +        "nonzero exit code ignores the webhook.",
    +    )
     
         webhook_subparsers.add_parser(
             "list", aliases=["ls"], help="List all dynamic subscriptions"
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -10932,6 +10932,7 @@ class WebhookCreate(BaseModel):
         description: Optional[str] = None
         events: List[str] = []
         prompt: Optional[str] = None
    +    script: Optional[str] = None
         skills: List[str] = []
         deliver: str = "log"
         deliver_only: bool = False
    ```

    ```diff
    @@ -10948,6 +10949,7 @@ def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> D
             "deliver": route.get("deliver", "log"),
             "deliver_only": bool(route.get("deliver_only")),
             "prompt": route.get("prompt", ""),
    +        "script": route.get("script", ""),
             "skills": list(route.get("skills") or []),
             "created_at": route.get("created_at"),
             "url": f"{base_url}/webhooks/{name}",
    ```

    ```diff
    @@ -11031,6 +11033,8 @@ async def create_webhook(body: WebhookCreate):
             "deliver": body.deliver or "log",
             "created_at": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
         }
    +    if body.script and body.script.strip():
    +        route["script"] = body.script.strip()
         if body.deliver_only:
             route["deliver_only"] = True
         if body.deliver_chat_id:
    ```
  - `hermes_cli/webhook.py`

    ```diff
    @@ -189,6 +189,10 @@ def _cmd_subscribe(args):
                 return
             route["deliver_only"] = True
     
    +    script = getattr(args, "script", "") or ""
    +    if script.strip():
    +        route["script"] = script.strip()
    +
         if args.deliver_chat_id:
             route["deliver_extra"] = {"chat_id": args.deliver_chat_id}
     
    ```

    ```diff
    @@ -212,6 +216,8 @@ def _cmd_subscribe(args):
             prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "")
             label = "Message" if route.get("deliver_only") else "Prompt"
             print(f"  {label}: {prompt_preview}")
    +    if route.get("script"):
    +        print(f"  Script: {route['script']}")
         print("\n  Configure your service to POST to the URL above.")
         print("  Use the secret for HMAC-SHA256 signature validation.")
         print("  The gateway must be running to receive events (hermes gateway run).\n")
    ```

    ```diff
    @@ -238,6 +244,8 @@ def _cmd_list(args):
             print(f"    URL:     {base_url}/webhooks/{name}")
             print(f"    Events:  {events}")
             print(f"    Deliver: {deliver}")
    +        if route.get("script"):
    +            print(f"    Script:  {route['script']}")
             print()
     
     
    ```
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "evelynburger",  # PR #57544 salvage (gateway: webhook payload filters + route scripts; commit under unlinked identity)
         "[redacted:email]": "SiteupAgencia",  # PR #57435 salvage (tui_gateway: back off notification poller when session is busy; #55578)
         "164521089+rainbowgits@users.noreply.github.com": "rainbowgore",  # PR #59405 salvage (mcp: bound stdio initialize handshake to stop subprocess/FD leak; #59349)
         "[redacted:email]": "thestudionorth",  # PR #60015 salvage (mcp: parent-death watchdog for stdio children; commit under unlinked local identity)
    ```
  - `tests/gateway/test_webhook_adapter.py`

    ```diff
    @@ -608,6 +608,285 @@ async def test_event_filter_accepts_payload_type_field(self):
                 assert resp.status == 202
     
     
    +# ===================================================================
    +# Payload filters
    +# ===================================================================
    +
    +
    +class TestPayloadFilters:
    +    """Tests for route-level payload filters in _handle_webhook."""
    +
    +    @pytest.mark.asyncio
    +    async def test_filter_rejects_before_agent_dispatch(self):
    +        """A non-matching filter returns ignored and never starts the agent."""
    +        routes = {
    +            "todoist": {
    +                "secret": [redacted:secret-assignment]
    +                "filters": [{"field": "payload.label", "equals": "urgent"}],
    +                "prompt": "Task: {payload.content}",
    +            }
    +        }
    +        adapter = _make_adapter(routes=routes)
    +        adapter.handle_message = AsyncMock()
    +
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            resp = await cli.post(
    +                "/webhooks/todoist",
    +                json={"payload": {"label": "later", "content": "Buy milk"}},
    +                headers={"X-GitHub-Delivery": "filter-skip-1"},
    +            )
    +            assert resp.status == 200
    +            data = await resp.json()
    +            assert data == {
    +                "status": "ignored",
    +                "reason": "filter",
    +                "route": "todoist",
    +            }
    +
    +        adapter.handle_message.assert_not_called()
    +        assert "filter-skip-1" not in adapter._seen_deliveries
    +
    +    @pytest.mark.asyncio
    +    async def test_filter_accepts_nested_any_and_in_file(self, tmp_path, monkeypatch):
    +        """Nested any groups can match dynamic watchlists under HERMES_HOME."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        watchlist = tmp_path / "data" / "watchlist.json"
    +        watchlist.parent.mkdir()
    +        watchlist.write_text(json.dumps(["chat-1", "chat-2"]), encoding="utf-8")
    +        routes = {
    +            "waha": {
    +                "secret": [redacted:secret-assignment]
    +                "filters": [
    +                    {"field": "payload.fromMe", "equals": False},
    +                    {
    +                        "any": [
    +                            {
    +                                "field": "payload.chatId",
    +                                "in_file": "~/.hermes/data/watchlist.json",
    +                            },
    +                            {
    +                                "field": "payload.id.remote",
    +                                "in_file": "~/.hermes/data/watchlist.json",
    +                            },
    +                        ]
    +                    },
    +                ],
    +                "prompt": "Message from {payload.chatId}: {payload.body}",
    +            }
    +        }
    +        adapter = _make_adapter(routes=routes)
    +        captured = []
    +
    +        async def _capture(event):
    +            captured.append(event)
    +
    +        adapter.handle_message = _capture
    +
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            resp = await cli.post(
    +                "/webhooks/waha",
    +                json={
    +                    "payload": {
    +                        "fromMe": False,
    +                        "chatId": "chat-2",
    +                        "body": "hello",
    +                    }
    +                },
    +                headers={"X-GitHub-Delivery": "filter-match-1"},
    +            )
    +            assert resp.status == 202
    +
    +        await asyncio.sleep(0.05)
    +        assert len(captured) == 1
    +        assert captured[0].text == "Message from chat-2: hello"
    +
    +    @pytest.mark.asyncio
    +    async def test_filter_applies_to_deliver_only_before_delivery(self):
    +        """Filtered direct-delivery routes skip target delivery too."""
    +        routes = {
    +            "alerts": {
    +                "secret": [redacted:secret-assignment]
    +                "deliver": "telegram",
    +                "deliver_only": True,
    +                "deliver_extra": {"chat_id": "123"},
    +                "filters": [{"field": "severity", "in": ["critical"]}],
    +                "prompt": "Alert: {message}",
    +            }
    +        }
    +        adapter = _make_adapter(routes=routes)
    +        mock_target = AsyncMock()
    +        mock_target.send = AsyncMock(return_value=SendResult(success=True))
    +        mock_runner = MagicMock()
    +        mock_runner.adapters = {Platform.TELEGRAM: mock_target}
    +        adapter.gateway_runner = mock_runner
    +
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            resp = await cli.post(
    +                "/webhooks/alerts",
    +                json={"severity": "info", "message": "noise"},
    +                headers={"X-GitHub-Delivery": "filter-direct-1"},
    +            )
    +            assert resp.status == 200
    +            assert (await resp.json())["reason"] == "filter"
    +
    +        mock_target.send.assert_not_called()
    +
    +    @pytest.mark.asyncio
    +    async def test_script_transforms_payload_before_prompt_rendering(self, tmp_path, monkeypatch):
    +        """A script can replace the payload used by prompt templates."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        scripts = tmp_path / "scripts"
    +        scripts.mkdir()
    +        script = scripts / "todoist_filter.py"
    +        script.write_text(
    +            "import json, sys\n"
    +            "payload = json.load(sys.stdin)\n"
    +            "payload['body'] = payload['task']['content'].upper()\n"
    +            "print(json.dumps(payload))\n",
    +            encoding="utf-8",
    +        )
    +        routes = {
    +            "todoist": {
    +                "secret": [redacted:secret-assignment]
    +                "script": "todoist_filter.py",
    +                "prompt": "Task: {body}",
    +            }
    +        }
    +        adapter = _make_adapter(routes=routes)
    +        captured = []
    +
    +        async def _capture(event):
    +            captured.append(event)
    +
    +        adapter.handle_message = _capture
    +
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            resp = await cli.post(
    +                "/webhooks/todoist",
    +                json={"task": {"content": "pay bills"}},
    +                headers={"X-GitHub-Delivery": "script-transform-1"},
    +            )
    +            assert resp.status == 202
    +
    +        await asyncio.sleep(0.05)
    +        assert captured[0].text == "Task: PAY BILLS"
    +        assert captured[0].raw_message["body"] == "PAY BILLS"
    +
    +    @pytest.mark.asyncio
    +    async def test_script_tilde_hermes_path_resolves_to_active_profile_home(self, tmp_path, monkeypatch):
    +        """~/.hermes/scripts paths must resolve through HERMES_HOME for profiles."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        scripts = tmp_path / "scripts"
    +        scripts.mkdir()
    +        (scripts / "todoist_filter.py").write_text(
    +            "import json, sys\n"
    +            "payload = json.load(sys.stdin)\n"
    +            "payload['body'] = 'profile-safe'\n"
    +            "print(json.dumps(payload))\n",
    +            encoding="utf-8",
    +        )
    +        routes = {
    +            "todoist": {
    +                "secret": [redacted:secret-assignment]
    +                "script": "~/.hermes/scripts/todoist_filter.py",
    +                "prompt": "Task: {body}",
    +            }
    +        }
    +        adapter = _make_adapter(routes=routes)
    +        captured = []
    +
    +        async def _capture(event):
    +            captured.append(event)
    +
    +        adapter.handle_message = _capture
    +
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            resp = await cli.post(
    +                "/webhooks/todoist",
    +                json={"task": {"content": "pay bills"}},
    +                headers={"X-GitHub-Delivery": "script-profile-path-1"},
    +            )
    +            assert resp.status == 202
    +
    +        await asyncio.sleep(0.05)
    +        assert captured[0].text == "Task: profile-safe"
    +
    +    @pytest.mark.asyncio
    +    async def test_script_silent_stdout_ignores_without_idempotency_hit(self, tmp_path, monkeypatch):
    +        """Empty or [SILENT] script stdout filters the webhook out."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        scripts = tmp_path / "scripts"
    +        scripts.mkdir()
    +        (scripts / "skip.py").write_text("print('[SILENT]')\n", encoding="utf-8")
    +        routes = {
    +            "todoist": {
    +                "secret": [redacted:secret-assignment]
    +                "script": "skip.py",
    +                "prompt": "Task: {body}",
    +            }
    +        }
    +        adapter = _make_adapter(routes=routes)
    +        adapter.handle_message = AsyncMock()
    +
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            resp = await cli.post(
    +                "/webhooks/todoist",
    +                json={"body": "ignore me"},
    +                headers={"X-GitHub-Delivery": "script-silent-1"},
    +            )
    +            assert resp.status == 200
    +            data = await resp.json()
    +            assert data == {
    +                "status": "ignored",
    +                "reason": "script",
    +                "route": "todoist",
    +            }
    +
    +        adapter.handle_message.assert_not_called()
    +        assert "script-silent-1" not in adapter._seen_deliveries
    +
    +    @pytest.mark.asyncio
    +    async def test_script_nonzero_exit_ignores_webhook(self, tmp_path, monkeypatch):
    +        """A script can fail closed by exiting nonzero."""
    +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +        scripts = tmp_path / "scripts"
    +        scripts.mkdir()
    +        (scripts / "skip.py").write_text(
    +            "import sys\nsys.exit(2)\n",
    +            encoding="utf-8",
    +        )
    +        routes = {
    +            "todoist": {
    +                "secret": [redacted:secret-assignment]
    +                "script": "skip.py",
    +                "prompt": "Task: {body}",
    +            }
    +        }
    +        adapter = _make_adapter(routes=routes)
    +        adapter.handle_message = AsyncMock()
    +
    +        app = _create_app(adapter)
    +        async with TestClient(TestServer(app)) as cli:
    +            resp = await cli.post(
    +                "/webhooks/todoist",
    +                json={"body": "ignore me"},
    +                headers={"X-GitHub-Delivery": "script-nonzero-1"},
    +            )
    +            assert resp.status == 200
    +            data = await resp.json()
    +            assert data["status"] == "ignored"
    +            assert data["reason"] == "script"
    +
    +        adapter.handle_message.assert_not_called()
    +        assert "script-nonzero-1" not in adapter._seen_deliveries
    +
    +
     # ===================================================================
     # HTTP handling
     # ===================================================================
    ```

    ```diff
    @@ -1246,4 +1525,3 @@ async def test_connect_allows_real_secret_on_public_bind(self):
                 assert result is True
             finally:
                 await adapter.disconnect()
    -
    ```
  - `tests/hermes_cli/test_dashboard_admin_endpoints.py`

    ```diff
    @@ -223,6 +223,30 @@ def test_list_disabled_and_create_blocked(self):
             r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"})
             assert r.status_code == 400
     
    +    def test_create_webhook_persists_script(self):
    +        from hermes_cli.config import load_config, save_config
    +
    +        cfg = load_config()
    +        cfg.setdefault("platforms", {})["webhook"] = {
    +            "enabled": True,
    +            "extra": {"host": "0.0.0.0", "port": 8644},
    +        }
    +        save_config(cfg)
    +
    +        r = self.client.post(
    +            "/api/webhooks",
    +            json={
    +                "name": "todoist",
    +                "deliver": "log",
    +                "script": "todoist_filter.py",
    +            },
    +        )
    +        assert r.status_code == 200
    +        assert r.json()["script"] == "todoist_filter.py"
    +
    +        subs = self.client.get("/api/webhooks").json()["subscriptions"]
    +        assert subs[0]["script"] == "todoist_filter.py"
    +
         def test_enable_platform_starts_gateway_restart(self, monkeypatch):
             import hermes_cli.web_server as ws
             from hermes_cli.config import load_config
    ```
  - `tests/hermes_cli/test_webhook_cli.py`

    ```diff
    @@ -35,6 +35,7 @@ def _make_args(**kwargs):
             "deliver_chat_id": "",
             "secret": "",
             "payload": "",
    +        "script": "",
         }
         defaults.update(kwargs)
         return Namespace(**defaults)
    ```

    ```diff
    @@ -72,6 +73,12 @@ def test_custom_secret(self):
             ))
             assert _load_subscriptions()["s"]["secret"] == "my-secret"
     
    +    def test_script_option_is_persisted(self):
    +        webhook_command(_make_args(
    +            webhook_action="subscribe", name="s", script="todoist_filter.py"
    +        ))
    +        assert _load_subscriptions()["s"]["script"] == "todoist_filter.py"
    +
         def test_auto_secret(self):
             webhook_command(_make_args(webhook_action="subscribe", name="s"))
             secret = _load_subscriptions()["s"]["secret"]
    ```
  - `website/docs/guides/webhook-github-pr-review.md`

    ```diff
    @@ -182,12 +182,20 @@ tail -f "${HERMES_HOME:-$HOME/.hermes}/logs/gateway.log"
     
     ## Filtering to specific actions
     
    -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.
    +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`.
     
     The prompt in Step 1 already handles this by instructing the agent to stop early for `closed` and `labeled` events.
     
     :::warning The agent still runs and consumes tokens
    -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.
    +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:
    +
    +```yaml
    +filters:
    +  - field: "action"
    +    in: ["opened", "synchronize", "reopened"]
    +```
    +
    +For high-volume repositories, you can still filter upstream with a GitHub Actions workflow that calls your webhook URL conditionally.
     :::
     
     > 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.
    ```
  - `website/docs/user-guide/messaging/webhooks.md`

    ```diff
    @@ -81,6 +81,8 @@ Routes define how different webhook sources are handled. Each route is a named e
     | `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. |
     | `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). |
     | `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). |
    +| `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. |
    +| `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. |
     | `skills` | No | List of skill names to load for the agent run. |
     | `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). |
     | `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`. |
    ```

    ```diff
    @@ -116,9 +118,75 @@ platforms:
               events: ["push"]
               secret: "[redacted:secret-assignment]"
               prompt: "New push to {repository.full_name} branch {ref}: {head_commit.message}"
    +          filters:
    +            - field: "ref"
    +              equals: "refs/heads/main"
               deliver: "telegram"
     ```
     
    +### Payload Filters
    +
    +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.
    +
    +```yaml
    +platforms:
    +  webhook:
    +    extra:
    +      routes:
    +        todoist:
    +          events: ["item:updated"]
    +          secret: "[redacted:secret-assignment]"
    +          filters:
    +            - field: "payload.labels"
    +              contains: "hermes"
    +            - any:
    +                - field: "payload.priority"
    +                  equals: 4
    +                - field: "payload.project_id"
    +                  in_file: "~/.hermes/data/todoist/watchlist.json"
    +          prompt: "Todoist task changed: {payload.content}"
    +```
    +
    +Supported operators:
    +
    +- `exists: true|false`
    +- `missing: true`
    +- `equals` / `not_equals`
    +- `contains` for strings, lists, and dict keys
    +- `in` for inline lists
    +- `in_file` for JSON arrays, JSON objects (keys are used), or newline-delimited text files
    +- `regex`
    +- `all`, `any`, and `not` groups
    +
    +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.
    +
    +### Script Filters and Transforms
    +
    +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.
    +
    +The route payload is sent to stdin as JSON:
    +
    +```python
    +# ~/.hermes/scripts/todoist-hermes-label.py
    +import json
    +import sys
    +
    +payload = json.load(sys.stdin)
    +labels = payload.get("payload", {}).get("labels", [])
    +if "hermes" not in labels:
    +    print("[SILENT]")
    +    raise SystemExit(0)
    +
    +payload["body"] = payload["payload"]["content"]
    +print(json.dumps(payload))
    +```
    +
    +Script outcomes:
    +
    +- JSON object stdout replaces the payload used by `prompt` and `deliver_extra`.
    +- Non-JSON text stdout is added to the payload as `script_output`.
    +- Empty stdout, exact `[SILENT]`, `{"__hermes_ignore__": true}`, timeout, missing script, or nonzero exit code returns HTTP 200 with `{"status":"ignored","reason":"script"}`.
    +
     ### Prompt Templates
     
     Prompts use dot-notation to access nested fields in the webhook payload:
    ```
- **#57544: feat(gateway): add webhook payload filters and route scripts**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#60944`

## Fix status

- Merged to main in PR #60944 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-08`.
- Subsystem: `gateway`
