diff --git a/docs/configuration.md b/docs/configuration.md index c80680366..de88a7790 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1968,8 +1968,10 @@ Use the HMAC header when the sender supports request signing. Bearer-style heade "provider": "github", "secret": "${GITHUB_WEBHOOK_SECRET}", "to": "discord:repo-events", + "events": ["pull_request"], + "actions": ["opened", "synchronize", "reopened", "ready_for_review"], "thread": "github:{{ github.repository_full_name }}:{{ github.pull_request.number or github.issue.number or github.ref }}", - "prompt": "Handle {{ github.event }} {{ github.action }} for {{ github.repository_full_name }}.\n\n{{ body }}" + "prompt": "Review {{ github.repository_full_name }} PR #{{ github.pull_request.number }} after {{ github.action }}.\n\n{{ body }}" } } } @@ -1977,6 +1979,7 @@ Use the HMAC header when the sender supports request signing. Bearer-style heade ``` For `provider: "github"`, nanobot validates GitHub's `X-Hub-Signature-256` HMAC header and deduplicates deliveries by `X-GitHub-Delivery` for `dedupeTtlS` seconds. +Use `events` and `actions` to keep setup pings, issue events, or unrelated PR actions from starting an agent turn. ### Template data @@ -2000,10 +2003,12 @@ Common template variables: | `webhooks.enabled` | `true` | Enables the webhook subsystem. | | `webhooks.routes..enabled` | `true` | Enables one route. Route names may contain letters, numbers, `_`, `.`, and `-`. | | `webhooks.routes..path` | `/webhooks/` | HTTP path served by the gateway. `/health` is reserved. | -| `webhooks.routes..provider` | `generic` | `generic` or `github`. Provider controls signature and context handling. | +| `webhooks.routes..provider` | `generic` | Registered webhook provider. Built-ins are `generic` and `github`; provider controls signature and context handling. | | `webhooks.routes..auth` | `secret` | `secret` or `none`. | | `webhooks.routes..secret` | empty | Shared secret or signing secret. Use `${ENV_VAR}` placeholders for real deployments. | | `webhooks.routes..to` | empty | Required target address in `channel:chat` format. | +| `webhooks.routes..events` | `[]` | Optional provider event-name allowlist. For GitHub this matches `X-GitHub-Event`, such as `pull_request`. | +| `webhooks.routes..actions` | `[]` | Optional JSON payload `action` allowlist, such as `opened` or `synchronize`. | | `webhooks.routes..thread` | empty | Optional Jinja template for the session key. Defaults to `to`. | | `webhooks.routes..prompt` | empty | Optional Jinja template for the inbound agent message. | | `webhooks.routes..sender` | `webhook` | Sender ID placed on the inbound message. | diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 125eec9c2..6712102ba 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -313,6 +313,8 @@ class WebhookRouteConfig(Base): to: str = "" # Required when enabled, e.g. "websocket:github" or "telegram:12345". thread: str = "" # Optional explicit session key; defaults to ``to``. prompt: str = "" # Optional Jinja template; a generic event summary is used when empty. + events: list[str] = Field(default_factory=list) # Optional provider event-name allowlist. + actions: list[str] = Field(default_factory=list) # Optional JSON payload action allowlist. sender: str = "webhook" max_body_bytes: int = Field(default=1_048_576, ge=1024, le=10_485_760) dedupe_ttl_s: int = Field(default=3_600, ge=0, le=86_400) @@ -328,6 +330,8 @@ class WebhookRouteConfig(Base): raise ValueError("webhook route path must start with '/'") if "?" in self.path or "#" in self.path or any(ch.isspace() for ch in self.path): raise ValueError("webhook route path must be a clean absolute path") + self.events = _clean_webhook_filter("events", self.events) + self.actions = _clean_webhook_filter("actions", self.actions) return self @@ -387,6 +391,13 @@ def _lazy_default(module_path: str, class_name: str) -> Any: return getattr(module, class_name)() +def _clean_webhook_filter(name: str, values: list[str]) -> list[str]: + cleaned = [value.strip() for value in values] + if any(not value for value in cleaned): + raise ValueError(f"webhook route {name} must not contain empty values") + return cleaned + + class ToolsConfig(Base): """Tools configuration. diff --git a/nanobot/webhooks.py b/nanobot/webhooks.py index b50aca1e5..1a2eb4667 100644 --- a/nanobot/webhooks.py +++ b/nanobot/webhooks.py @@ -155,6 +155,16 @@ class WebhookRouter: remote=remote, ) delivery_id = context.get("delivery_id") + if not _route_filter_allows(route, context): + return { + "ok": True, + "queued": False, + "ignored": True, + "route": name, + "event": context.get("event_name") or "", + "action": context.get("action") or "", + "delivery_id": delivery_id or None, + } prompt = _render_prompt(route, context) channel, chat_id = _parse_target(route.to) thread = _render_thread(route, context) or route.to @@ -311,6 +321,7 @@ def _template_context( provider_context = _webhook_provider(route.provider).context(headers, event) event_name = provider_context.pop("event_name", "") delivery_id = provider_context.pop("delivery_id", "") + action = _event_action(event, provider_context) return { "route": { "name": name, @@ -326,6 +337,7 @@ def _template_context( "body": body_text, "headers": _safe_headers(headers), "remote": remote or "", + "action": action, "github": provider_context.get("github", {}), "event_name": event_name, "delivery_id": delivery_id, @@ -333,6 +345,33 @@ def _template_context( } +def _event_action(event: Mapping[str, Any], provider_context: Mapping[str, Any]) -> str: + github = provider_context.get("github") + if isinstance(github, Mapping): + action = github.get("action") + if isinstance(action, str): + return action + action = event.get("action") + return action if isinstance(action, str) else "" + + +def _route_filter_allows(route: WebhookRouteConfig, context: Mapping[str, Any]) -> bool: + return _filter_matches(route.events, context.get("event_name")) and _filter_matches( + route.actions, context.get("action") + ) + + +def _filter_matches(allowed: list[str], value: Any) -> bool: + if not allowed: + return True + normalized = _filter_value(value) + return normalized in {_filter_value(item) for item in allowed} + + +def _filter_value(value: Any) -> str: + return value.strip().lower() if isinstance(value, str) else "" + + def _generic_context(headers: Mapping[str, str], _payload: Mapping[str, Any]) -> dict[str, Any]: return { "event_name": headers.get("x-nanobot-event", ""), diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index d2444aa66..468ea38e1 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -274,6 +274,76 @@ async def test_github_webhook_validates_signature_and_dedupes_delivery() -> None assert msg.metadata["webhook"]["event"] == "pull_request" +@pytest.mark.asyncio +async def test_github_webhook_event_and_action_filters_ignore_unmatched_events() -> None: + bus = MessageBus() + router = WebhookRouter( + WebhooksConfig( + routes={ + "github": WebhookRouteConfig( + provider="github", + secret="github-secret", + to="discord:repo-events", + events=["pull_request"], + actions=["opened", "synchronize"], + ) + } + ), + bus, + ) + + async def send(payload: dict[str, object], event: str, delivery: str): + body = json.dumps(payload).encode() + return await router.handle( + method="POST", + path="/webhooks/github", + headers={ + "X-Hub-Signature-256": _sig("github-secret", body), + "X-GitHub-Event": event, + "X-GitHub-Delivery": delivery, + }, + body=body, + ) + + ping = await send({"zen": "Keep it logically awesome."}, "ping", "ping-1") + closed = await send( + { + "action": "closed", + "repository": {"full_name": "HKUDS/nanobot"}, + "pull_request": {"title": "Add webhook support"}, + }, + "pull_request", + "pr-closed-1", + ) + opened = await send( + { + "action": "opened", + "repository": {"full_name": "HKUDS/nanobot"}, + "pull_request": {"title": "Add webhook support"}, + }, + "pull_request", + "pr-opened-1", + ) + + assert ping is not None + assert ping.status == 202 + assert ping.body["queued"] is False + assert ping.body["ignored"] is True + assert ping.body["event"] == "ping" + assert closed is not None + assert closed.status == 202 + assert closed.body["queued"] is False + assert closed.body["ignored"] is True + assert closed.body["action"] == "closed" + assert opened is not None + assert opened.status == 202 + assert opened.body["queued"] is True + assert bus.inbound_size == 1 + msg = await bus.consume_inbound() + assert "Event: pull_request" in msg.content + assert "Action: opened" in msg.content + + @pytest.mark.asyncio async def test_github_webhook_rejects_invalid_signature() -> None: bus = MessageBus()