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.
- agentrust/__init__.py +72 -0
- agentrust_py-0.0.3.dist-info/METADATA +193 -0
- agentrust_py-0.0.3.dist-info/RECORD +29 -0
- agentrust_py-0.0.3.dist-info/WHEEL +4 -0
- agentrust_py-0.0.3.dist-info/entry_points.txt +2 -0
- agentrust_py-0.0.3.dist-info/licenses/LICENSE +177 -0
- agentrust_sdk/__init__.py +124 -0
- agentrust_sdk/adapters/__init__.py +1 -0
- agentrust_sdk/adapters/autogen.py +235 -0
- agentrust_sdk/adapters/claude_agents.py +225 -0
- agentrust_sdk/adapters/crewai.py +98 -0
- agentrust_sdk/adapters/langgraph.py +109 -0
- agentrust_sdk/adapters/mcp.py +193 -0
- agentrust_sdk/adapters/openai_agents.py +263 -0
- agentrust_sdk/auth.py +192 -0
- agentrust_sdk/auto.py +397 -0
- agentrust_sdk/autoload.py +95 -0
- agentrust_sdk/cli.py +736 -0
- agentrust_sdk/client.py +790 -0
- agentrust_sdk/config.py +192 -0
- agentrust_sdk/decorator.py +276 -0
- agentrust_sdk/embedded.py +428 -0
- agentrust_sdk/hooks.py +461 -0
- agentrust_sdk/models.py +81 -0
- agentrust_sdk/py.typed +0 -0
- agentrust_sdk/queue_replay.py +204 -0
- agentrust_sdk/tiers.py +180 -0
- agentrust_sdk/version_negotiation.py +290 -0
- agentrust_sdk/webhooks.py +782 -0
|
@@ -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
|
+
)
|