agentrust-py 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,782 @@
1
+ """
2
+ AgentTrust SDK — Webhook dispatcher.
3
+
4
+ Inspired by the Adrian OSS webhook notification system, this module provides
5
+ a tier-gated, configurable webhook dispatcher that fires HTTP notifications
6
+ to registered endpoints whenever a validation result matches the configured
7
+ decision filter.
8
+
9
+ Supported platforms
10
+ -------------------
11
+ - discord : Rich colour-coded embeds with decision outcome, risk tier, and
12
+ agent details. Adrian-style formatting with severity colours.
13
+ - http : Plain JSON payload; compatible with Slack incoming webhooks,
14
+ PagerDuty, Linear, and any generic HTTP receiver.
15
+
16
+ Tier requirement
17
+ ----------------
18
+ Capability.WEBHOOKS requires **Team** tier or above. On lower tiers the
19
+ dispatcher is constructed but ``dispatch()`` / ``async_dispatch()`` are
20
+ no-ops that emit a single ``logger.debug`` message.
21
+
22
+ Usage (Team tier and above)
23
+ ---------------------------
24
+ Programmatic::
25
+
26
+ from agentrust_sdk.webhooks import WebhookDispatcher
27
+
28
+ dispatcher = WebhookDispatcher(tier=client.tier)
29
+ dispatcher.register(
30
+ url="https://discord.com/api/webhooks/1234/token",
31
+ events=["block", "escalate"],
32
+ name="alerts-channel",
33
+ )
34
+
35
+ # AgentTrustClient calls this automatically when a dispatcher is attached.
36
+ # You can also call it manually after validate():
37
+ from agentrust_sdk.webhooks import event_from_response
38
+ result = client.validate(agent_id="my-agent", user="alice", input="Pay $500")
39
+ dispatcher.dispatch(event_from_response(result, agent_id="my-agent"))
40
+
41
+ Via environment variables (zero code required)::
42
+
43
+ AGENTRUST_WEBHOOK_URL=https://discord.com/api/webhooks/...
44
+ AGENTRUST_WEBHOOK_EVENTS=block,escalate # or "all"
45
+
46
+ Environment variables
47
+ ---------------------
48
+ AGENTRUST_WEBHOOK_URL Single webhook URL — bootstrapped on dispatcher init.
49
+ AGENTRUST_WEBHOOK_EVENTS Comma-separated decision filter: all|approve|block|escalate
50
+ Defaults to "all" when AGENTRUST_WEBHOOK_URL is set.
51
+ AGENTRUST_WEBHOOK_DB SQLite path for the webhook registry.
52
+ Default: ~/.agentrust/webhooks.db
53
+ AGENTRUST_WEBHOOK_TIMEOUT HTTP timeout in seconds for each dispatch call (default 5).
54
+ """
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ import logging
59
+ import os
60
+ import sqlite3
61
+ import uuid
62
+ from datetime import datetime, timezone
63
+ from pathlib import Path
64
+ from typing import Any
65
+ from urllib.parse import urlparse
66
+
67
+ import httpx
68
+ from pydantic import BaseModel, Field
69
+
70
+ from .config import SDK_CONFIG
71
+ from .tiers import Capability, Tier, is_allowed, UPGRADE_MESSAGES
72
+
73
+ logger = logging.getLogger(__name__)
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Constants
77
+ # ---------------------------------------------------------------------------
78
+
79
+ _DISCORD_HOSTS = (
80
+ "https://discord.com/api/webhooks/",
81
+ "https://discordapp.com/api/webhooks/",
82
+ )
83
+
84
+ # Colour palette mirrors Adrian: M4-red / M3-amber / neutral
85
+ _DECISION_COLORS: dict[str, int] = {
86
+ "block": 0xFF4757, # danger red
87
+ "escalate": 0xFFA502, # warning amber
88
+ "request_evidence": 0xFFA502, # warning amber
89
+ "approve": 0x2ED573, # success green
90
+ "pending": 0x6B6B80, # muted neutral
91
+ }
92
+
93
+ _VISIBLE_TOKEN_SUFFIX = 8 # chars of a webhook token shown in masked form
94
+
95
+ _VALID_EVENTS = frozenset({"all", "approve", "block", "escalate", "request_evidence"})
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Pydantic models
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def _utcnow_iso() -> str:
103
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
104
+
105
+
106
+ class WebhookConfig(BaseModel):
107
+ """Full (internal) representation of a registered webhook."""
108
+
109
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
110
+ url: str
111
+ events: list[str] = Field(default_factory=lambda: ["all"])
112
+ platform: str = "http" # "discord" | "http"
113
+ name: str = ""
114
+ enabled: bool = True
115
+ created_at: str = Field(default_factory=_utcnow_iso)
116
+
117
+
118
+ class WebhookConfigPublic(BaseModel):
119
+ """Safe-to-expose representation: the URL's secret token is masked."""
120
+
121
+ id: str
122
+ url_masked: str
123
+ events: list[str]
124
+ platform: str
125
+ name: str
126
+ enabled: bool
127
+ created_at: str
128
+
129
+
130
+ class WebhookEvent(BaseModel):
131
+ """The normalised event payload fired to every matching webhook endpoint."""
132
+
133
+ envelope_id: str
134
+ agent_id: str
135
+ decision: str # "approve" | "block" | "escalate" | "request_evidence" | "pending"
136
+ risk_tier: str = "unknown"
137
+ risk_score: float = 0.0
138
+ session_id: str | None = None
139
+ framework: str = "REST"
140
+ timestamp: str = Field(default_factory=_utcnow_iso)
141
+ sdk_version: str = Field(default_factory=lambda: SDK_CONFIG.sdk_version)
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # URL helpers
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def is_discord_url(url: str) -> bool:
149
+ """Return True if *url* points at a Discord webhook endpoint."""
150
+ return any(url.startswith(h) for h in _DISCORD_HOSTS)
151
+
152
+
153
+ def validate_webhook_url(url: str) -> None:
154
+ """
155
+ Raise ``ValueError`` if *url* is not a valid HTTPS webhook endpoint.
156
+
157
+ Mirrors Adrian's ``ValidateDiscordWebhookURL`` but is broader:
158
+ any well-formed HTTPS URL is accepted; Discord URLs get an extra host
159
+ prefix check via ``is_discord_url`` at registration time (informational
160
+ only — non-Discord URLs are allowed as generic HTTP targets).
161
+ """
162
+ if not url.startswith("https://"):
163
+ raise ValueError(
164
+ f"Webhook URL must use HTTPS. "
165
+ f"Got: {url!r}. "
166
+ f"Discord example: https://discord.com/api/webhooks/..."
167
+ )
168
+ parsed = urlparse(url)
169
+ if not parsed.netloc:
170
+ raise ValueError(f"Invalid webhook URL (no host): {url!r}")
171
+ if not parsed.path or parsed.path == "/":
172
+ raise ValueError(f"Webhook URL must include a path: {url!r}")
173
+
174
+
175
+ def mask_url(url: str) -> str:
176
+ """
177
+ Return the URL with its secret token replaced by a fixed prefix + last
178
+ ``_VISIBLE_TOKEN_SUFFIX`` characters.
179
+
180
+ Discord shape: https://discord.com/api/webhooks/<id>/<token>
181
+ Generic shape: anything after the last '/' is treated as the secret.
182
+
183
+ Mirrors Adrian's ``store.MaskedURL`` logic exactly.
184
+ """
185
+ last_slash = url.rfind("/")
186
+ if last_slash == -1 or last_slash == len(url) - 1:
187
+ return url
188
+ token = url[last_slash + 1:]
189
+ if len(token) > _VISIBLE_TOKEN_SUFFIX:
190
+ visible = token[-_VISIBLE_TOKEN_SUFFIX:]
191
+ return url[: last_slash + 1] + "...***" + visible
192
+ return url
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Event-filter matcher
197
+ # ---------------------------------------------------------------------------
198
+
199
+ def _matches_filter(event_filter: list[str], decision: str) -> bool:
200
+ """
201
+ Return True when *decision* should trigger the webhook.
202
+
203
+ ``"all"`` in the filter matches every decision outcome (mirrors Adrian's
204
+ ``alertTypeMatches`` with filter == "all"). Other filter entries are
205
+ exact-matched against the decision string.
206
+ """
207
+ if "all" in event_filter:
208
+ return True
209
+ return decision in event_filter
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # SQLite persistence (consistent with agentrust_sdk's queue.db pattern)
214
+ # ---------------------------------------------------------------------------
215
+
216
+ def _db_path() -> Path:
217
+ return Path(
218
+ os.environ.get(
219
+ "AGENTRUST_WEBHOOK_DB",
220
+ str(Path.home() / ".agentrust" / "webhooks.db"),
221
+ )
222
+ )
223
+
224
+
225
+ def _open_db(path: Path) -> sqlite3.Connection:
226
+ """Open (creating if needed) the webhook registry SQLite database."""
227
+ path.parent.mkdir(parents=True, exist_ok=True)
228
+ con = sqlite3.connect(str(path))
229
+ con.execute("PRAGMA journal_mode=WAL")
230
+ con.execute("PRAGMA synchronous=NORMAL")
231
+ con.execute("PRAGMA foreign_keys=ON")
232
+ con.execute(
233
+ """
234
+ CREATE TABLE IF NOT EXISTS webhooks (
235
+ id TEXT PRIMARY KEY,
236
+ url TEXT NOT NULL,
237
+ events TEXT NOT NULL DEFAULT '["all"]',
238
+ platform TEXT NOT NULL DEFAULT 'http',
239
+ name TEXT NOT NULL DEFAULT '',
240
+ enabled INTEGER NOT NULL DEFAULT 1
241
+ CHECK (enabled IN (0,1)),
242
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
243
+ )
244
+ """
245
+ )
246
+ con.commit()
247
+ return con
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Payload builders
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def _build_discord_payload(event: WebhookEvent) -> dict[str, Any]:
255
+ """
256
+ Build a Discord webhook POST body with a colour-coded rich embed.
257
+
258
+ Colour coding mirrors Adrian's ``buildPayload``:
259
+ block / escalate → danger red / warning amber
260
+ approve → success green
261
+ other → neutral grey
262
+ """
263
+ color = _DECISION_COLORS.get(event.decision, _DECISION_COLORS["pending"])
264
+
265
+ title_map = {
266
+ "block": "AgentTrust: Agent Blocked",
267
+ "escalate": "AgentTrust: Human Review Required",
268
+ "request_evidence": "AgentTrust: Evidence Requested",
269
+ "approve": "AgentTrust: Action Approved",
270
+ "pending": "AgentTrust: Validation Pending",
271
+ }
272
+ title = title_map.get(event.decision, "AgentTrust: Validation Result")
273
+
274
+ desc_map = {
275
+ "block": (
276
+ "An agent action was **blocked** by AgentTrust. "
277
+ "Review the decision details and inspect the agent output."
278
+ ),
279
+ "escalate": (
280
+ "An agent action requires **human review** before proceeding. "
281
+ "Approve or reject it via your AgentTrust dashboard."
282
+ ),
283
+ "request_evidence": (
284
+ "AgentTrust is requesting additional **evidence** "
285
+ "before approving this action."
286
+ ),
287
+ "approve": "An agent action was **approved** by AgentTrust.",
288
+ "pending": "AgentTrust validation completed with a **pending** status.",
289
+ }
290
+ desc = desc_map.get(event.decision, "AgentTrust validation completed.")
291
+
292
+ fields: list[dict[str, Any]] = [
293
+ {"name": "Decision", "value": event.decision.upper(), "inline": True},
294
+ {"name": "Risk tier", "value": event.risk_tier, "inline": True},
295
+ {"name": "Risk score", "value": f"{event.risk_score:.2f}", "inline": True},
296
+ {"name": "Agent", "value": event.agent_id, "inline": True},
297
+ {"name": "Framework", "value": event.framework, "inline": True},
298
+ ]
299
+ if event.session_id:
300
+ fields.append({"name": "Session", "value": event.session_id, "inline": False})
301
+
302
+ return {
303
+ "content": (
304
+ f"AgentTrust alert: **{event.decision.upper()}** "
305
+ f"— agent `{event.agent_id}`"
306
+ ),
307
+ "embeds": [
308
+ {
309
+ "title": title,
310
+ "description": desc,
311
+ "color": color,
312
+ "fields": fields,
313
+ "timestamp": event.timestamp,
314
+ "footer": {
315
+ "text": (
316
+ f"envelope: {event.envelope_id} "
317
+ f"· sdk v{event.sdk_version}"
318
+ )
319
+ },
320
+ }
321
+ ],
322
+ }
323
+
324
+
325
+ def _build_http_payload(event: WebhookEvent) -> dict[str, Any]:
326
+ """
327
+ Build a generic JSON payload for non-Discord HTTP webhook receivers.
328
+
329
+ Compatible with Slack incoming webhooks, PagerDuty events API, Linear,
330
+ and any endpoint that accepts JSON.
331
+ """
332
+ return {
333
+ "source": "agentrust-sdk",
334
+ "sdk_version": event.sdk_version,
335
+ "event": {
336
+ "envelope_id": event.envelope_id,
337
+ "agent_id": event.agent_id,
338
+ "decision": event.decision,
339
+ "risk_tier": event.risk_tier,
340
+ "risk_score": event.risk_score,
341
+ "session_id": event.session_id,
342
+ "framework": event.framework,
343
+ "timestamp": event.timestamp,
344
+ },
345
+ }
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # WebhookDispatcher
350
+ # ---------------------------------------------------------------------------
351
+
352
+ class WebhookDispatcher:
353
+ """
354
+ Manages webhook registrations and dispatches ``WebhookEvent`` objects to
355
+ all matching, enabled endpoints after each validation call.
356
+
357
+ The dispatcher is tier-gated: ``Capability.WEBHOOKS`` requires **Team**
358
+ tier or above. On lower tiers the constructor succeeds (so code paths
359
+ stay clean) but all dispatch calls silently no-op.
360
+
361
+ Persistence
362
+ -----------
363
+ Webhook registrations are stored in a local SQLite database at
364
+ ``~/.agentrust/webhooks.db`` (overridden by ``AGENTRUST_WEBHOOK_DB``).
365
+ This mirrors the existing ``queue.db`` pattern in the SDK's client module.
366
+
367
+ Quick-start via env vars
368
+ ------------------------
369
+ Set these before your process starts::
370
+
371
+ AGENTRUST_WEBHOOK_URL=https://discord.com/api/webhooks/...
372
+ AGENTRUST_WEBHOOK_EVENTS=block,escalate
373
+
374
+ The dispatcher bootstraps a webhook from these env vars on ``__init__``
375
+ so you don't need any code changes beyond attaching the dispatcher to the
376
+ client.
377
+
378
+ Thread safety
379
+ -------------
380
+ SQLite connections are opened and closed per-call (no shared connection
381
+ state), so the dispatcher is safe to use from multiple threads without
382
+ additional locking.
383
+ """
384
+
385
+ def __init__(
386
+ self,
387
+ tier: Tier = Tier.OSS,
388
+ timeout: float | None = None,
389
+ *,
390
+ _db_override: Path | None = None,
391
+ ) -> None:
392
+ self._tier = tier
393
+ self._allowed = is_allowed(Capability.WEBHOOKS, tier)
394
+ self._timeout = (
395
+ timeout
396
+ if timeout is not None
397
+ else float(os.environ.get("AGENTRUST_WEBHOOK_TIMEOUT", "5"))
398
+ )
399
+ self._db = _db_override or _db_path()
400
+
401
+ if not self._allowed:
402
+ logger.debug(
403
+ "[AgentTrust] WebhookDispatcher: tier=%s does not support webhooks "
404
+ "(requires Team). Dispatch calls will be no-ops.",
405
+ tier.value,
406
+ )
407
+ return
408
+
409
+ # Bootstrap env-var webhook at startup (idempotent — checks for duplicates).
410
+ env_url = os.environ.get("AGENTRUST_WEBHOOK_URL", "").strip()
411
+ if env_url:
412
+ env_events_raw = os.environ.get("AGENTRUST_WEBHOOK_EVENTS", "all").strip()
413
+ env_events = [e.strip() for e in env_events_raw.split(",") if e.strip()]
414
+ try:
415
+ existing = self._load_from_db()
416
+ if not any(w.url == env_url for w in existing):
417
+ self.register(
418
+ url=env_url,
419
+ events=env_events,
420
+ name="env-config",
421
+ )
422
+ logger.info(
423
+ "[AgentTrust] Webhook bootstrapped from AGENTRUST_WEBHOOK_URL"
424
+ " (events=%s)", env_events,
425
+ )
426
+ except Exception as exc:
427
+ logger.warning(
428
+ "[AgentTrust] Failed to register env-var webhook: %s", exc
429
+ )
430
+
431
+ # ── Registration ─────────────────────────────────────────────────────────
432
+
433
+ def register(
434
+ self,
435
+ url: str,
436
+ events: list[str] | None = None,
437
+ name: str = "",
438
+ platform: str | None = None,
439
+ enabled: bool = True,
440
+ ) -> WebhookConfigPublic:
441
+ """
442
+ Register a new webhook destination.
443
+
444
+ Parameters
445
+ ----------
446
+ url:
447
+ The HTTPS webhook endpoint. Discord URLs are auto-detected; any
448
+ valid HTTPS URL is accepted as a generic HTTP target.
449
+ events:
450
+ List of decision outcomes that trigger dispatch.
451
+ Valid values: ``"all"``, ``"approve"``, ``"block"``,
452
+ ``"escalate"``, ``"request_evidence"``.
453
+ Defaults to ``["all"]``.
454
+ name:
455
+ Human-readable label shown in ``list_webhooks()`` (optional).
456
+ platform:
457
+ ``"discord"`` or ``"http"``. Auto-detected from the URL if
458
+ not provided.
459
+ enabled:
460
+ Set to ``False`` to register a webhook without activating it.
461
+
462
+ Returns
463
+ -------
464
+ ``WebhookConfigPublic`` with the URL's secret token masked.
465
+
466
+ Raises
467
+ ------
468
+ ``TierGateWebhookError``
469
+ When the dispatcher's tier is below Team.
470
+ ``ValueError``
471
+ When the URL fails basic HTTPS validation.
472
+ """
473
+ if not self._allowed:
474
+ raise TierGateWebhookError(
475
+ UPGRADE_MESSAGES.get(
476
+ Capability.WEBHOOKS,
477
+ "Webhooks require Team tier ($149/mo). "
478
+ "Upgrade: agentrust upgrade",
479
+ )
480
+ )
481
+
482
+ validate_webhook_url(url)
483
+
484
+ _events = events if events is not None else ["all"]
485
+ _platform = platform or ("discord" if is_discord_url(url) else "http")
486
+
487
+ cfg = WebhookConfig(
488
+ url=url,
489
+ events=_events,
490
+ platform=_platform,
491
+ name=name,
492
+ enabled=enabled,
493
+ )
494
+
495
+ con = _open_db(self._db)
496
+ try:
497
+ con.execute(
498
+ "INSERT INTO webhooks "
499
+ "(id, url, events, platform, name, enabled, created_at) "
500
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
501
+ (
502
+ cfg.id,
503
+ cfg.url,
504
+ json.dumps(cfg.events),
505
+ cfg.platform,
506
+ cfg.name,
507
+ int(cfg.enabled),
508
+ cfg.created_at,
509
+ ),
510
+ )
511
+ con.commit()
512
+ finally:
513
+ con.close()
514
+
515
+ logger.info(
516
+ "[AgentTrust] Webhook registered: id=%s platform=%s events=%s name=%r",
517
+ cfg.id, cfg.platform, cfg.events, cfg.name,
518
+ )
519
+
520
+ return WebhookConfigPublic(
521
+ id=cfg.id,
522
+ url_masked=mask_url(cfg.url),
523
+ events=cfg.events,
524
+ platform=cfg.platform,
525
+ name=cfg.name,
526
+ enabled=cfg.enabled,
527
+ created_at=cfg.created_at,
528
+ )
529
+
530
+ def list_webhooks(self) -> list[WebhookConfigPublic]:
531
+ """
532
+ Return all registered webhooks with masked URLs.
533
+
534
+ Safe to call on any tier — returns an empty list when not allowed.
535
+ """
536
+ return [
537
+ WebhookConfigPublic(
538
+ id=w.id,
539
+ url_masked=mask_url(w.url),
540
+ events=w.events,
541
+ platform=w.platform,
542
+ name=w.name,
543
+ enabled=w.enabled,
544
+ created_at=w.created_at,
545
+ )
546
+ for w in self._load_from_db()
547
+ ]
548
+
549
+ def delete_webhook(self, webhook_id: str) -> bool:
550
+ """
551
+ Delete a registered webhook by its ID.
552
+
553
+ Returns ``True`` if the record was found and deleted, ``False``
554
+ if no record matched the given ID.
555
+ """
556
+ con = _open_db(self._db)
557
+ try:
558
+ cur = con.execute(
559
+ "DELETE FROM webhooks WHERE id = ?", (webhook_id,)
560
+ )
561
+ con.commit()
562
+ deleted = cur.rowcount > 0
563
+ finally:
564
+ con.close()
565
+
566
+ if deleted:
567
+ logger.info("[AgentTrust] Webhook deleted: id=%s", webhook_id)
568
+ return deleted
569
+
570
+ # ── Synchronous dispatch ──────────────────────────────────────────────────
571
+
572
+ def dispatch(self, event: WebhookEvent) -> None:
573
+ """
574
+ Synchronously fan out *event* to all matching, enabled webhooks.
575
+
576
+ Mirrors Adrian's ``fanout`` method in ``notifications/dispatcher.go``:
577
+ - Loads enabled webhooks from the registry.
578
+ - Skips webhooks whose event filter does not match the decision.
579
+ - POSTs to each matching webhook with a 5-second timeout.
580
+ - Logs a warning on individual send failures; never propagates errors
581
+ to the caller (fail-open: the agent's execution is not interrupted).
582
+
583
+ No-ops silently when the dispatcher's tier is below Team.
584
+ """
585
+ if not self._allowed:
586
+ logger.debug(
587
+ "[AgentTrust] Webhook dispatch skipped: tier=%s (requires Team)",
588
+ self._tier.value,
589
+ )
590
+ return
591
+
592
+ hooks = [w for w in self._load_from_db() if w.enabled]
593
+ if not hooks:
594
+ return
595
+
596
+ for hook in hooks:
597
+ if not _matches_filter(hook.events, event.decision):
598
+ continue
599
+ payload = (
600
+ _build_discord_payload(event)
601
+ if hook.platform == "discord"
602
+ else _build_http_payload(event)
603
+ )
604
+ try:
605
+ with httpx.Client(timeout=self._timeout) as http:
606
+ resp = http.post(
607
+ hook.url,
608
+ json=payload,
609
+ headers=_webhook_headers(event.sdk_version),
610
+ )
611
+ if resp.status_code < 300:
612
+ logger.debug(
613
+ "[AgentTrust] Webhook dispatched: id=%s decision=%s status=%d",
614
+ hook.id, event.decision, resp.status_code,
615
+ )
616
+ else:
617
+ logger.warning(
618
+ "[AgentTrust] Webhook returned non-2xx: id=%s status=%d body=%r",
619
+ hook.id, resp.status_code, resp.text[:256],
620
+ )
621
+ except Exception as exc:
622
+ logger.warning(
623
+ "[AgentTrust] Webhook dispatch error: id=%s error=%s",
624
+ hook.id, exc,
625
+ )
626
+
627
+ # ── Asynchronous dispatch ─────────────────────────────────────────────────
628
+
629
+ async def async_dispatch(self, event: WebhookEvent) -> None:
630
+ """
631
+ Asynchronously fan out *event* to all matching, enabled webhooks.
632
+
633
+ Behaviour is identical to ``dispatch()`` but uses
634
+ ``httpx.AsyncClient``. Each webhook POST is awaited sequentially
635
+ (matching Adrian's simple loop fanout) to avoid spawning unbounded
636
+ tasks. Errors per-webhook are logged as warnings and never propagated.
637
+
638
+ No-ops silently when the dispatcher's tier is below Team.
639
+ """
640
+ if not self._allowed:
641
+ logger.debug(
642
+ "[AgentTrust] Async webhook dispatch skipped: tier=%s (requires Team)",
643
+ self._tier.value,
644
+ )
645
+ return
646
+
647
+ hooks = [w for w in self._load_from_db() if w.enabled]
648
+ if not hooks:
649
+ return
650
+
651
+ for hook in hooks:
652
+ if not _matches_filter(hook.events, event.decision):
653
+ continue
654
+ payload = (
655
+ _build_discord_payload(event)
656
+ if hook.platform == "discord"
657
+ else _build_http_payload(event)
658
+ )
659
+ try:
660
+ async with httpx.AsyncClient(timeout=self._timeout) as http:
661
+ resp = await http.post(
662
+ hook.url,
663
+ json=payload,
664
+ headers=_webhook_headers(event.sdk_version),
665
+ )
666
+ if resp.status_code < 300:
667
+ logger.debug(
668
+ "[AgentTrust] Async webhook dispatched: id=%s decision=%s status=%d",
669
+ hook.id, event.decision, resp.status_code,
670
+ )
671
+ else:
672
+ logger.warning(
673
+ "[AgentTrust] Async webhook returned non-2xx: id=%s status=%d body=%r",
674
+ hook.id, resp.status_code, resp.text[:256],
675
+ )
676
+ except Exception as exc:
677
+ logger.warning(
678
+ "[AgentTrust] Async webhook dispatch error: id=%s error=%s",
679
+ hook.id, exc,
680
+ )
681
+
682
+ # ── Private helpers ───────────────────────────────────────────────────────
683
+
684
+ def _load_from_db(self) -> list[WebhookConfig]:
685
+ """Load all webhook configs from the local SQLite registry."""
686
+ try:
687
+ con = _open_db(self._db)
688
+ try:
689
+ rows = con.execute(
690
+ "SELECT id, url, events, platform, name, enabled, created_at "
691
+ "FROM webhooks ORDER BY created_at ASC"
692
+ ).fetchall()
693
+ finally:
694
+ con.close()
695
+ except Exception as exc:
696
+ logger.warning(
697
+ "[AgentTrust] Failed to load webhooks from DB (%s): %s",
698
+ self._db, exc,
699
+ )
700
+ return []
701
+
702
+ result: list[WebhookConfig] = []
703
+ for row in rows:
704
+ try:
705
+ events = json.loads(row[2]) if row[2] else ["all"]
706
+ result.append(
707
+ WebhookConfig(
708
+ id=row[0],
709
+ url=row[1],
710
+ events=events,
711
+ platform=row[3],
712
+ name=row[4],
713
+ enabled=bool(row[5]),
714
+ created_at=row[6],
715
+ )
716
+ )
717
+ except Exception as exc:
718
+ logger.warning(
719
+ "[AgentTrust] Skipping malformed webhook row id=%r: %s",
720
+ row[0], exc,
721
+ )
722
+ return result
723
+
724
+
725
+ # ---------------------------------------------------------------------------
726
+ # Headers helper
727
+ # ---------------------------------------------------------------------------
728
+
729
+ def _webhook_headers(sdk_version: str) -> dict[str, str]:
730
+ return {
731
+ "Content-Type": "application/json",
732
+ "User-Agent": f"AgentTrust-SDK-Webhook/{sdk_version}",
733
+ "X-AgentTrust-SDK-Version": sdk_version,
734
+ }
735
+
736
+
737
+ # ---------------------------------------------------------------------------
738
+ # TierGateWebhookError
739
+ # ---------------------------------------------------------------------------
740
+
741
+ class TierGateWebhookError(PermissionError):
742
+ """
743
+ Raised when webhook operations are attempted on a tier below Team.
744
+
745
+ Inherits ``PermissionError`` so callers can catch it with the standard
746
+ built-in exception hierarchy without importing this module.
747
+ """
748
+
749
+
750
+ # ---------------------------------------------------------------------------
751
+ # Convenience builder: ValidateResponse → WebhookEvent
752
+ # ---------------------------------------------------------------------------
753
+
754
+ def event_from_response(
755
+ response: Any,
756
+ agent_id: str,
757
+ framework: str = "REST",
758
+ session_id: str | None = None,
759
+ ) -> WebhookEvent:
760
+ """
761
+ Build a ``WebhookEvent`` from a ``client.validate()`` response.
762
+
763
+ This is the bridge the ``AgentTrustClient`` uses to convert a
764
+ ``ValidateResponse`` into the normalised event the dispatcher expects.
765
+ You can also call it manually when integrating the dispatcher into
766
+ custom validation flows::
767
+
768
+ from agentrust_sdk.webhooks import WebhookDispatcher, event_from_response
769
+
770
+ result = client.validate(agent_id="pay-agent", user="alice", input="Pay $500")
771
+ dispatcher.dispatch(event_from_response(result, agent_id="pay-agent"))
772
+ """
773
+ return WebhookEvent(
774
+ envelope_id=response.envelope_id,
775
+ agent_id=agent_id,
776
+ decision=response.decision.outcome if response.decision else "pending",
777
+ risk_tier=response.risk.tier if response.risk else "unknown",
778
+ risk_score=response.risk.score if response.risk else 0.0,
779
+ session_id=session_id,
780
+ framework=framework,
781
+ sdk_version=SDK_CONFIG.sdk_version,
782
+ )