detecte 0.1.1__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.
detecte/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Detecte — runtime security for AI agents.
2
+
3
+ Quickstart::
4
+
5
+ from detecte import Detecte
6
+
7
+ detecte = Detecte(api_key=os.environ["DETECTE_API_KEY"])
8
+
9
+ decision = detecte.verify(
10
+ agent="support_bot",
11
+ action="refund_order",
12
+ params={"order_id": "ord_8821", "amount": 49.99},
13
+ )
14
+
15
+ if not decision.allowed:
16
+ raise RuntimeError(f"Blocked: {decision.reason}")
17
+ """
18
+
19
+ from .client import Detecte, AsyncDetecte
20
+ from .errors import (
21
+ DetecteError,
22
+ DetecteApiError,
23
+ DetecteAuthError,
24
+ DetecteValidationError,
25
+ DetecteRateLimitError,
26
+ DetecteNetworkError,
27
+ DetecteTimeoutError,
28
+ )
29
+ from .types import (
30
+ Decision,
31
+ Agent,
32
+ Policy,
33
+ Incident,
34
+ AuditEntry,
35
+ Approval,
36
+ PolicyEvaluation,
37
+ )
38
+ from .webhooks import verify_webhook, WebhookVerificationError
39
+
40
+ __version__ = "0.1.1"
41
+
42
+ __all__ = [
43
+ "Detecte",
44
+ "AsyncDetecte",
45
+ "DetecteError",
46
+ "DetecteApiError",
47
+ "DetecteAuthError",
48
+ "DetecteValidationError",
49
+ "DetecteRateLimitError",
50
+ "DetecteNetworkError",
51
+ "DetecteTimeoutError",
52
+ "Decision",
53
+ "Agent",
54
+ "Policy",
55
+ "Incident",
56
+ "AuditEntry",
57
+ "Approval",
58
+ "PolicyEvaluation",
59
+ "verify_webhook",
60
+ "WebhookVerificationError",
61
+ "__version__",
62
+ ]
detecte/client.py ADDED
@@ -0,0 +1,332 @@
1
+ """Top-level Detecte client (sync + async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Literal, Optional
7
+
8
+ from .http import SyncHttp, AsyncHttp, DEFAULT_BASE_URL
9
+ from .errors import DetecteApiError, DetecteNetworkError
10
+ from .types import Decision, Agent, Policy, Incident, AuditEntry, Approval
11
+
12
+ Failsafe = Literal["fail_open", "fail_closed", "fail_silent"]
13
+
14
+
15
+ def _resolve_key(api_key: Optional[str]) -> str:
16
+ k = api_key or os.environ.get("DETECTE_API_KEY")
17
+ if not k:
18
+ raise ValueError("api_key not provided and DETECTE_API_KEY not set")
19
+ return k
20
+
21
+
22
+ def _fallback_decision(reason: str) -> Decision:
23
+ return Decision.model_validate(
24
+ {
25
+ "id": "dec_fallback",
26
+ "allowed": True,
27
+ "status": "allowed",
28
+ "reason": reason,
29
+ "policies_evaluated": [],
30
+ "risk_delta": 0,
31
+ "approval_url": None,
32
+ "metadata": {"latency_ms": 0},
33
+ }
34
+ )
35
+
36
+
37
+ class Detecte:
38
+ """Synchronous Detecte client."""
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: Optional[str] = None,
43
+ *,
44
+ base_url: str = DEFAULT_BASE_URL,
45
+ timeout: float = 5.0,
46
+ retries: int = 2,
47
+ failsafe: Failsafe = "fail_open",
48
+ ):
49
+ self._http = SyncHttp(_resolve_key(api_key), base_url, timeout, retries)
50
+ self._failsafe = failsafe
51
+ self.agents = _AgentsResource(self._http)
52
+ self.policies = _PoliciesResource(self._http)
53
+ self.incidents = _IncidentsResource(self._http)
54
+ self.audit = _AuditResource(self._http)
55
+ self.approvals = _ApprovalsResource(self._http)
56
+ self.scans = _ScansResource(self._http)
57
+
58
+ def verify(
59
+ self,
60
+ *,
61
+ agent: str,
62
+ action: str,
63
+ params: Optional[dict[str, Any]] = None,
64
+ context: Optional[dict[str, Any]] = None,
65
+ sensitive: Optional[list[str]] = None,
66
+ session_id: Optional[str] = None,
67
+ idempotency_key: Optional[str] = None,
68
+ ) -> Decision:
69
+ body = {
70
+ "agent": agent,
71
+ "action": action,
72
+ "params": params or {},
73
+ "context": context or {},
74
+ }
75
+ if sensitive:
76
+ body["sensitive"] = sensitive
77
+ if session_id:
78
+ body["sessionId"] = session_id
79
+ if idempotency_key:
80
+ body["idempotencyKey"] = idempotency_key
81
+ try:
82
+ data = self._http.request("POST", "/v1/verify", json=body)
83
+ return Decision.model_validate(data)
84
+ except DetecteNetworkError:
85
+ if self._failsafe == "fail_open":
86
+ return _fallback_decision("Detecte unreachable; fail_open")
87
+ if self._failsafe == "fail_closed":
88
+ return Decision.model_validate(
89
+ {
90
+ "id": "dec_fallback",
91
+ "allowed": False,
92
+ "status": "blocked",
93
+ "reason": "Detecte unreachable; fail_closed",
94
+ "policies_evaluated": [],
95
+ "risk_delta": 0,
96
+ "approval_url": None,
97
+ "metadata": {"latency_ms": 0},
98
+ }
99
+ )
100
+ raise
101
+
102
+
103
+ class AsyncDetecte:
104
+ """Asynchronous Detecte client."""
105
+
106
+ def __init__(
107
+ self,
108
+ api_key: Optional[str] = None,
109
+ *,
110
+ base_url: str = DEFAULT_BASE_URL,
111
+ timeout: float = 5.0,
112
+ retries: int = 2,
113
+ failsafe: Failsafe = "fail_open",
114
+ ):
115
+ self._http = AsyncHttp(_resolve_key(api_key), base_url, timeout, retries)
116
+ self._failsafe = failsafe
117
+ self.agents = _AsyncAgentsResource(self._http)
118
+ self.policies = _AsyncPoliciesResource(self._http)
119
+ self.incidents = _AsyncIncidentsResource(self._http)
120
+ self.audit = _AsyncAuditResource(self._http)
121
+ self.approvals = _AsyncApprovalsResource(self._http)
122
+ self.scans = _AsyncScansResource(self._http)
123
+
124
+ async def verify(
125
+ self,
126
+ *,
127
+ agent: str,
128
+ action: str,
129
+ params: Optional[dict[str, Any]] = None,
130
+ context: Optional[dict[str, Any]] = None,
131
+ sensitive: Optional[list[str]] = None,
132
+ session_id: Optional[str] = None,
133
+ idempotency_key: Optional[str] = None,
134
+ ) -> Decision:
135
+ body = {
136
+ "agent": agent,
137
+ "action": action,
138
+ "params": params or {},
139
+ "context": context or {},
140
+ }
141
+ if sensitive:
142
+ body["sensitive"] = sensitive
143
+ if session_id:
144
+ body["sessionId"] = session_id
145
+ if idempotency_key:
146
+ body["idempotencyKey"] = idempotency_key
147
+ try:
148
+ data = await self._http.request("POST", "/v1/verify", json=body)
149
+ return Decision.model_validate(data)
150
+ except DetecteNetworkError:
151
+ if self._failsafe == "fail_open":
152
+ return _fallback_decision("Detecte unreachable; fail_open")
153
+ if self._failsafe == "fail_closed":
154
+ return Decision.model_validate(
155
+ {
156
+ "id": "dec_fallback",
157
+ "allowed": False,
158
+ "status": "blocked",
159
+ "reason": "Detecte unreachable; fail_closed",
160
+ "policies_evaluated": [],
161
+ "risk_delta": 0,
162
+ "approval_url": None,
163
+ "metadata": {"latency_ms": 0},
164
+ }
165
+ )
166
+ raise
167
+
168
+
169
+ # ── Sync resources ──
170
+
171
+ class _AgentsResource:
172
+ def __init__(self, http: SyncHttp):
173
+ self._h = http
174
+
175
+ def list(self, limit: int = 50) -> list[Agent]:
176
+ data = self._h.request("GET", f"/v1/agents?limit={limit}") or {}
177
+ return [Agent.model_validate(a) for a in data.get("data", [])]
178
+
179
+ def get(self, agent_id: str) -> Agent:
180
+ return Agent.model_validate(self._h.request("GET", f"/v1/agents/{agent_id}"))
181
+
182
+ def create(self, **fields: Any) -> Agent:
183
+ return Agent.model_validate(self._h.request("POST", "/v1/agents", json=fields))
184
+
185
+ def update(self, agent_id: str, **fields: Any) -> Agent:
186
+ return Agent.model_validate(self._h.request("PATCH", f"/v1/agents/{agent_id}", json=fields))
187
+
188
+
189
+ class _PoliciesResource:
190
+ def __init__(self, http: SyncHttp):
191
+ self._h = http
192
+
193
+ def list(self, limit: int = 100) -> list[Policy]:
194
+ data = self._h.request("GET", f"/v1/policies?limit={limit}") or {}
195
+ return [Policy.model_validate(p) for p in data.get("data", [])]
196
+
197
+ def create(self, **fields: Any) -> Policy:
198
+ return Policy.model_validate(self._h.request("POST", "/v1/policies", json=fields))
199
+
200
+ def dry_run(self, *, policy: dict[str, Any], sample_size: int = 1000) -> dict[str, Any]:
201
+ return self._h.request(
202
+ "POST",
203
+ "/v1/policies/dry-run",
204
+ json={"policy": policy, "sample_size": sample_size},
205
+ )
206
+
207
+ def update(self, policy_id: str, **fields: Any) -> Policy:
208
+ return Policy.model_validate(self._h.request("PATCH", f"/v1/policies/{policy_id}", json=fields))
209
+
210
+ def delete(self, policy_id: str) -> None:
211
+ self._h.request("DELETE", f"/v1/policies/{policy_id}")
212
+
213
+
214
+ class _IncidentsResource:
215
+ def __init__(self, http: SyncHttp):
216
+ self._h = http
217
+
218
+ def list(self, limit: int = 50) -> list[Incident]:
219
+ data = self._h.request("GET", f"/v1/incidents?limit={limit}") or {}
220
+ return [Incident.model_validate(i) for i in data.get("data", [])]
221
+
222
+ def resolve(self, incident_id: str) -> Incident:
223
+ return Incident.model_validate(self._h.request("POST", f"/v1/incidents/{incident_id}/resolve"))
224
+
225
+
226
+ class _AuditResource:
227
+ def __init__(self, http: SyncHttp):
228
+ self._h = http
229
+
230
+ def list(self, **params: Any) -> list[AuditEntry]:
231
+ from urllib.parse import urlencode
232
+ qs = urlencode({k: v for k, v in params.items() if v is not None})
233
+ data = self._h.request("GET", f"/v1/audit?{qs}" if qs else "/v1/audit") or {}
234
+ return [AuditEntry.model_validate(a) for a in data.get("data", [])]
235
+
236
+
237
+ class _ApprovalsResource:
238
+ def __init__(self, http: SyncHttp):
239
+ self._h = http
240
+
241
+ def get(self, decision_id: str) -> Approval:
242
+ return Approval.model_validate(self._h.request("GET", f"/v1/approvals/{decision_id}"))
243
+
244
+ def wait(self, decision_id: str, *, timeout_ms: int = 5 * 60_000, poll_ms: int = 2000) -> Approval:
245
+ import time as _time
246
+ deadline = _time.time() + timeout_ms / 1000
247
+ while True:
248
+ a = self.get(decision_id)
249
+ if a.approved is not None:
250
+ return a
251
+ if _time.time() >= deadline:
252
+ return a
253
+ _time.sleep(poll_ms / 1000)
254
+
255
+
256
+ class _ScansResource:
257
+ def __init__(self, http: SyncHttp):
258
+ self._h = http
259
+
260
+ def run(self, agent: str, *, system_prompt: Optional[str] = None) -> dict[str, Any]:
261
+ body: dict[str, Any] = {"agent": agent}
262
+ if system_prompt is not None:
263
+ body["system_prompt"] = system_prompt
264
+ return self._h.request("POST", "/v1/scans", json=body)
265
+
266
+
267
+ # ── Async resources (wrappers) ──
268
+
269
+ class _AsyncAgentsResource:
270
+ def __init__(self, http: AsyncHttp):
271
+ self._h = http
272
+
273
+ async def list(self, limit: int = 50) -> list[Agent]:
274
+ data = (await self._h.request("GET", f"/v1/agents?limit={limit}")) or {}
275
+ return [Agent.model_validate(a) for a in data.get("data", [])]
276
+
277
+ async def get(self, agent_id: str) -> Agent:
278
+ return Agent.model_validate(await self._h.request("GET", f"/v1/agents/{agent_id}"))
279
+
280
+ async def create(self, **fields: Any) -> Agent:
281
+ return Agent.model_validate(await self._h.request("POST", "/v1/agents", json=fields))
282
+
283
+
284
+ class _AsyncPoliciesResource:
285
+ def __init__(self, http: AsyncHttp):
286
+ self._h = http
287
+
288
+ async def list(self, limit: int = 100) -> list[Policy]:
289
+ data = (await self._h.request("GET", f"/v1/policies?limit={limit}")) or {}
290
+ return [Policy.model_validate(p) for p in data.get("data", [])]
291
+
292
+ async def create(self, **fields: Any) -> Policy:
293
+ return Policy.model_validate(await self._h.request("POST", "/v1/policies", json=fields))
294
+
295
+
296
+ class _AsyncIncidentsResource:
297
+ def __init__(self, http: AsyncHttp):
298
+ self._h = http
299
+
300
+ async def list(self, limit: int = 50) -> list[Incident]:
301
+ data = (await self._h.request("GET", f"/v1/incidents?limit={limit}")) or {}
302
+ return [Incident.model_validate(i) for i in data.get("data", [])]
303
+
304
+
305
+ class _AsyncAuditResource:
306
+ def __init__(self, http: AsyncHttp):
307
+ self._h = http
308
+
309
+ async def list(self, **params: Any) -> list[AuditEntry]:
310
+ from urllib.parse import urlencode
311
+ qs = urlencode({k: v for k, v in params.items() if v is not None})
312
+ data = (await self._h.request("GET", f"/v1/audit?{qs}" if qs else "/v1/audit")) or {}
313
+ return [AuditEntry.model_validate(a) for a in data.get("data", [])]
314
+
315
+
316
+ class _AsyncApprovalsResource:
317
+ def __init__(self, http: AsyncHttp):
318
+ self._h = http
319
+
320
+ async def get(self, decision_id: str) -> Approval:
321
+ return Approval.model_validate(await self._h.request("GET", f"/v1/approvals/{decision_id}"))
322
+
323
+
324
+ class _AsyncScansResource:
325
+ def __init__(self, http: AsyncHttp):
326
+ self._h = http
327
+
328
+ async def run(self, agent: str, *, system_prompt: Optional[str] = None) -> dict[str, Any]:
329
+ body: dict[str, Any] = {"agent": agent}
330
+ if system_prompt is not None:
331
+ body["system_prompt"] = system_prompt
332
+ return await self._h.request("POST", "/v1/scans", json=body)
detecte/errors.py ADDED
@@ -0,0 +1,65 @@
1
+ """Detecte SDK error hierarchy. Mirrors @detecte/sdk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class DetecteError(Exception):
9
+ """Base class for all Detecte errors."""
10
+
11
+ code: str = "detecte_error"
12
+
13
+ def __init__(self, message: str, *, code: str | None = None):
14
+ super().__init__(message)
15
+ if code is not None:
16
+ self.code = code
17
+
18
+
19
+ class DetecteApiError(DetecteError):
20
+ """A non-2xx HTTP response from the API."""
21
+
22
+ code = "api_error"
23
+
24
+ def __init__(
25
+ self,
26
+ message: str,
27
+ *,
28
+ status: int,
29
+ body: Any = None,
30
+ code: str | None = None,
31
+ ):
32
+ super().__init__(message, code=code or "api_error")
33
+ self.status = status
34
+ self.body = body
35
+
36
+
37
+ class DetecteAuthError(DetecteApiError):
38
+ code = "auth_error"
39
+
40
+
41
+ class DetecteValidationError(DetecteApiError):
42
+ code = "validation_error"
43
+
44
+
45
+ class DetecteRateLimitError(DetecteApiError):
46
+ code = "rate_limit_exceeded"
47
+
48
+ def __init__(
49
+ self,
50
+ message: str,
51
+ *,
52
+ status: int = 429,
53
+ retry_after_ms: int | None = None,
54
+ body: Any = None,
55
+ ):
56
+ super().__init__(message, status=status, body=body, code="rate_limit_exceeded")
57
+ self.retry_after_ms = retry_after_ms
58
+
59
+
60
+ class DetecteNetworkError(DetecteError):
61
+ code = "network_error"
62
+
63
+
64
+ class DetecteTimeoutError(DetecteNetworkError):
65
+ code = "timeout"
detecte/http.py ADDED
@@ -0,0 +1,156 @@
1
+ """Sync + async HTTP client with retries, timeouts, and Detecte-specific error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, Optional
8
+
9
+ import httpx
10
+
11
+ from .errors import (
12
+ DetecteApiError,
13
+ DetecteAuthError,
14
+ DetecteNetworkError,
15
+ DetecteRateLimitError,
16
+ DetecteTimeoutError,
17
+ DetecteValidationError,
18
+ )
19
+
20
+ __VERSION__ = "0.1.1"
21
+ USER_AGENT = f"detecte-python/{__VERSION__}"
22
+ DEFAULT_BASE_URL = "https://api.detecte.xyz"
23
+
24
+
25
+ def _map_status(status: int, body: Any) -> type[DetecteApiError]:
26
+ if status == 401 or status == 403:
27
+ return DetecteAuthError
28
+ if status == 422:
29
+ return DetecteValidationError
30
+ if status == 429:
31
+ return DetecteRateLimitError
32
+ return DetecteApiError
33
+
34
+
35
+ def _retry_after_ms(response: httpx.Response) -> Optional[int]:
36
+ ra = response.headers.get("retry-after")
37
+ if not ra:
38
+ return None
39
+ try:
40
+ return int(float(ra) * 1000)
41
+ except (TypeError, ValueError):
42
+ return None
43
+
44
+
45
+ def _backoff(attempt: int) -> float:
46
+ return min(0.25 * (2 ** attempt) + 0.05 * attempt, 5.0)
47
+
48
+
49
+ class _BaseHttp:
50
+ def __init__(
51
+ self,
52
+ api_key: str,
53
+ base_url: str = DEFAULT_BASE_URL,
54
+ timeout_s: float = 5.0,
55
+ retries: int = 2,
56
+ ):
57
+ self.api_key = api_key
58
+ self.base_url = base_url.rstrip("/")
59
+ self.timeout_s = timeout_s
60
+ self.retries = retries
61
+
62
+ def _headers(self, extra: Optional[dict[str, str]] = None) -> dict[str, str]:
63
+ h = {
64
+ "Authorization": f"Bearer {self.api_key}",
65
+ "Content-Type": "application/json",
66
+ "User-Agent": USER_AGENT,
67
+ "Accept": "application/json",
68
+ }
69
+ if extra:
70
+ h.update(extra)
71
+ return h
72
+
73
+ def _raise_for_status(self, response: httpx.Response) -> None:
74
+ if 200 <= response.status_code < 300:
75
+ return
76
+ try:
77
+ body = response.json()
78
+ except Exception:
79
+ body = response.text
80
+ message = (
81
+ (body.get("message") if isinstance(body, dict) else None)
82
+ or (body if isinstance(body, str) else f"HTTP {response.status_code}")
83
+ )
84
+ cls = _map_status(response.status_code, body)
85
+ if cls is DetecteRateLimitError:
86
+ raise DetecteRateLimitError(
87
+ message,
88
+ status=response.status_code,
89
+ retry_after_ms=_retry_after_ms(response),
90
+ body=body,
91
+ )
92
+ raise cls(message, status=response.status_code, body=body)
93
+
94
+
95
+ class SyncHttp(_BaseHttp):
96
+ def request(self, method: str, path: str, *, json: Any = None, headers: Optional[dict[str, str]] = None) -> Any:
97
+ url = f"{self.base_url}{path}"
98
+ last_err: Optional[BaseException] = None
99
+ for attempt in range(self.retries + 1):
100
+ try:
101
+ with httpx.Client(timeout=self.timeout_s) as client:
102
+ response = client.request(method, url, json=json, headers=self._headers(headers))
103
+ if response.status_code == 429 and attempt < self.retries:
104
+ time.sleep((_retry_after_ms(response) or 1000) / 1000)
105
+ continue
106
+ if 500 <= response.status_code < 600 and attempt < self.retries:
107
+ time.sleep(_backoff(attempt))
108
+ continue
109
+ self._raise_for_status(response)
110
+ return response.json() if response.content else None
111
+ except httpx.TimeoutException as e:
112
+ last_err = e
113
+ if attempt < self.retries:
114
+ time.sleep(_backoff(attempt))
115
+ continue
116
+ raise DetecteTimeoutError(f"Timed out after {self.timeout_s}s")
117
+ except httpx.HTTPError as e:
118
+ last_err = e
119
+ if attempt < self.retries:
120
+ time.sleep(_backoff(attempt))
121
+ continue
122
+ raise DetecteNetworkError(str(e))
123
+ if last_err:
124
+ raise DetecteNetworkError(str(last_err))
125
+
126
+
127
+ class AsyncHttp(_BaseHttp):
128
+ async def request(self, method: str, path: str, *, json: Any = None, headers: Optional[dict[str, str]] = None) -> Any:
129
+ url = f"{self.base_url}{path}"
130
+ last_err: Optional[BaseException] = None
131
+ for attempt in range(self.retries + 1):
132
+ try:
133
+ async with httpx.AsyncClient(timeout=self.timeout_s) as client:
134
+ response = await client.request(method, url, json=json, headers=self._headers(headers))
135
+ if response.status_code == 429 and attempt < self.retries:
136
+ await asyncio.sleep((_retry_after_ms(response) or 1000) / 1000)
137
+ continue
138
+ if 500 <= response.status_code < 600 and attempt < self.retries:
139
+ await asyncio.sleep(_backoff(attempt))
140
+ continue
141
+ self._raise_for_status(response)
142
+ return response.json() if response.content else None
143
+ except httpx.TimeoutException as e:
144
+ last_err = e
145
+ if attempt < self.retries:
146
+ await asyncio.sleep(_backoff(attempt))
147
+ continue
148
+ raise DetecteTimeoutError(f"Timed out after {self.timeout_s}s")
149
+ except httpx.HTTPError as e:
150
+ last_err = e
151
+ if attempt < self.retries:
152
+ await asyncio.sleep(_backoff(attempt))
153
+ continue
154
+ raise DetecteNetworkError(str(e))
155
+ if last_err:
156
+ raise DetecteNetworkError(str(last_err))
File without changes
@@ -0,0 +1,68 @@
1
+ """LangChain integration: a callback handler that verifies every tool call.
2
+
3
+ Usage::
4
+
5
+ from langchain.agents import AgentExecutor
6
+ from detecte import Detecte
7
+ from detecte.integrations.langchain import DetecteCallbackHandler
8
+
9
+ detecte = Detecte(api_key=...)
10
+ executor = AgentExecutor.from_agent_and_tools(
11
+ agent=...,
12
+ tools=...,
13
+ callbacks=[DetecteCallbackHandler(detecte=detecte, agent_id="agent_xxx")],
14
+ )
15
+
16
+ When a blocked decision is encountered the handler raises a
17
+ ``DetecteToolBlocked`` so the executor's standard error path will surface the
18
+ refusal back into the agent loop.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any, Optional
24
+
25
+
26
+ class DetecteToolBlocked(RuntimeError):
27
+ """Raised by the callback when Detecte blocks a tool call."""
28
+
29
+
30
+ def _import_base_callback_handler() -> type:
31
+ try:
32
+ from langchain_core.callbacks.base import BaseCallbackHandler # type: ignore
33
+ except ImportError:
34
+ try:
35
+ from langchain.callbacks.base import BaseCallbackHandler # type: ignore
36
+ except ImportError as e:
37
+ raise ImportError(
38
+ "langchain (>=0.1) is required for DetecteCallbackHandler"
39
+ ) from e
40
+ return BaseCallbackHandler
41
+
42
+
43
+ def DetecteCallbackHandler(detecte: Any, agent_id: str) -> Any: # noqa: N802 — public name
44
+ """Construct a LangChain callback handler tied to a Detecte client + agent_id."""
45
+ Base = _import_base_callback_handler()
46
+
47
+ class _Handler(Base): # type: ignore[misc, valid-type]
48
+ def on_tool_start(
49
+ self,
50
+ serialized: dict[str, Any],
51
+ input_str: str,
52
+ *,
53
+ run_id: Optional[str] = None,
54
+ **kwargs: Any,
55
+ ) -> None:
56
+ tool_name = serialized.get("name") or "unknown"
57
+ decision = detecte.verify(
58
+ agent=agent_id,
59
+ action=tool_name,
60
+ params={"input": input_str},
61
+ context={"run_id": str(run_id) if run_id else None},
62
+ )
63
+ if not decision.allowed:
64
+ raise DetecteToolBlocked(
65
+ decision.reason or f"Detecte blocked tool {tool_name}"
66
+ )
67
+
68
+ return _Handler()
File without changes
detecte/types.py ADDED
@@ -0,0 +1,86 @@
1
+ """Pydantic models mirroring @detecte/sdk's Zod schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, Optional
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ Tier = Literal["low", "medium", "high", "restricted"]
10
+ DecisionStatus = Literal["allowed", "blocked", "escalated", "pending_approval"]
11
+
12
+
13
+ class _Base(BaseModel):
14
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
15
+
16
+
17
+ class PolicyEvaluation(_Base):
18
+ id: str
19
+ name: str
20
+ result: str
21
+
22
+
23
+ class DecisionMetadata(_Base):
24
+ latency_ms: int = 0
25
+
26
+
27
+ class Decision(_Base):
28
+ id: str
29
+ allowed: bool
30
+ status: DecisionStatus
31
+ reason: Optional[str] = None
32
+ policies_evaluated: list[PolicyEvaluation] = Field(default_factory=list)
33
+ risk_delta: float = 0
34
+ approval_url: Optional[str] = None
35
+ expires_at: Optional[str] = None
36
+ metadata: DecisionMetadata = Field(default_factory=DecisionMetadata)
37
+
38
+
39
+ class Agent(_Base):
40
+ id: str
41
+ name: str
42
+ description: Optional[str] = None
43
+ tier: Tier = "medium"
44
+ declared_capabilities: list[str] = Field(default_factory=list)
45
+ risk_score: int = 0
46
+ created_at: Optional[str] = None
47
+
48
+
49
+ class Policy(_Base):
50
+ id: str
51
+ name: str
52
+ agents: list[str] = Field(default_factory=list)
53
+ when: dict[str, Any] = Field(default_factory=dict)
54
+ then: dict[str, Any] = Field(default_factory=dict)
55
+ enabled: bool = True
56
+ created_at: Optional[str] = None
57
+
58
+
59
+ class Incident(_Base):
60
+ id: str
61
+ agent: Optional[str] = None
62
+ decision_id: Optional[str] = None
63
+ severity: Literal["low", "medium", "high", "critical"] = "medium"
64
+ status: Literal["open", "acknowledged", "resolved"] = "open"
65
+ summary: str
66
+ created_at: str
67
+ resolved_at: Optional[str] = None
68
+
69
+
70
+ class AuditEntry(_Base):
71
+ id: str
72
+ ts: str
73
+ agent: Optional[str] = None
74
+ action: Optional[str] = None
75
+ decision_id: Optional[str] = None
76
+ status: Optional[DecisionStatus] = None
77
+ actor: Optional[str] = None
78
+
79
+
80
+ class Approval(_Base):
81
+ id: str
82
+ decision_id: str
83
+ approved: Optional[bool] = None
84
+ reason: Optional[str] = None
85
+ approver: Optional[str] = None
86
+ approval_url: Optional[str] = None
detecte/webhooks.py ADDED
@@ -0,0 +1,64 @@
1
+ """Webhook signature verification (Stripe-style HMAC-SHA256)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import time
8
+ from typing import Optional
9
+
10
+
11
+ class WebhookVerificationError(Exception):
12
+ """Raised when a webhook signature can't be verified."""
13
+
14
+
15
+ def verify_webhook(
16
+ *,
17
+ payload: str | bytes,
18
+ signature: str,
19
+ secret: str,
20
+ tolerance_s: int = 5 * 60,
21
+ ) -> None:
22
+ """Raise WebhookVerificationError unless the signature is valid.
23
+
24
+ `signature` is the `Detecte-Signature` header value, formatted as
25
+ ``t=<unix>,v1=<hex>``.
26
+ """
27
+
28
+ parts = dict(p.split("=", 1) for p in signature.split(",") if "=" in p)
29
+ ts = parts.get("t")
30
+ v1 = parts.get("v1")
31
+ if not ts or not v1:
32
+ raise WebhookVerificationError("malformed signature header")
33
+
34
+ try:
35
+ ts_int = int(ts)
36
+ except ValueError:
37
+ raise WebhookVerificationError("invalid timestamp")
38
+
39
+ if abs(time.time() - ts_int) > tolerance_s:
40
+ raise WebhookVerificationError("timestamp outside tolerance")
41
+
42
+ if isinstance(payload, str):
43
+ payload_bytes = payload.encode("utf-8")
44
+ else:
45
+ payload_bytes = payload
46
+ signed = f"{ts}.".encode("utf-8") + payload_bytes
47
+ expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
48
+ if not hmac.compare_digest(expected, v1):
49
+ raise WebhookVerificationError("bad signature")
50
+
51
+
52
+ def is_valid_webhook(
53
+ *,
54
+ payload: str | bytes,
55
+ signature: str,
56
+ secret: str,
57
+ tolerance_s: int = 5 * 60,
58
+ ) -> bool:
59
+ """Convenience wrapper that returns a bool instead of raising."""
60
+ try:
61
+ verify_webhook(payload=payload, signature=signature, secret=secret, tolerance_s=tolerance_s)
62
+ return True
63
+ except WebhookVerificationError:
64
+ return False
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: detecte
3
+ Version: 0.1.1
4
+ Summary: Runtime security for AI agents — Python SDK for detecte.xyz
5
+ Project-URL: Homepage, https://detecte.xyz
6
+ Project-URL: Documentation, https://docs.detecte.xyz
7
+ Project-URL: Repository, https://github.com/detecte-xyz/detecte-python
8
+ Project-URL: Issues, https://github.com/detecte-xyz/detecte-python/issues
9
+ Author: Detecte
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,ai,kya,llm,policy,runtime,security
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx>=0.25
25
+ Requires-Dist: pydantic>=2.0
26
+ Requires-Dist: typing-extensions>=4.5
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.10; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
30
+ Requires-Dist: pytest>=8; extra == 'dev'
31
+ Requires-Dist: respx>=0.21; extra == 'dev'
32
+ Requires-Dist: ruff>=0.5; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # detecte (Python)
36
+
37
+ [![PyPI](https://img.shields.io/pypi/v/detecte.svg)](https://pypi.org/project/detecte/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/detecte.svg)](https://pypi.org/project/detecte/)
39
+
40
+ Runtime security for AI agents. The Python SDK for [detecte.xyz](https://detecte.xyz) — a one-to-one
41
+ companion to [`@detecte/sdk`](https://www.npmjs.com/package/@detecte/sdk).
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install detecte
47
+ ```
48
+
49
+ ## Quickstart
50
+
51
+ ```python
52
+ import os
53
+ from detecte import Detecte
54
+
55
+ detecte = Detecte(api_key=os.environ["DETECTE_API_KEY"])
56
+
57
+ decision = detecte.verify(
58
+ agent="support_bot",
59
+ action="refund_order",
60
+ params={"order_id": "ord_8821", "amount": 49.99},
61
+ )
62
+
63
+ if not decision.allowed:
64
+ raise RuntimeError(f"Blocked: {decision.reason}")
65
+ ```
66
+
67
+ ## Async
68
+
69
+ ```python
70
+ from detecte import AsyncDetecte
71
+
72
+ async def main():
73
+ detecte = AsyncDetecte(api_key="sk_test_...")
74
+ decision = await detecte.verify(agent="bot", action="x")
75
+ print(decision.allowed)
76
+ ```
77
+
78
+ ## Resources
79
+
80
+ - `detecte.agents.list() / .get(id) / .create(...)`
81
+ - `detecte.policies.list() / .create(...) / .dry_run(policy=..., sample_size=1000)`
82
+ - `detecte.incidents.list() / .resolve(id)`
83
+ - `detecte.audit.list(...)`
84
+ - `detecte.approvals.get(decision_id) / .wait(decision_id)`
85
+ - `detecte.scans.run(agent=..., system_prompt=...)`
86
+
87
+ ## Webhooks
88
+
89
+ ```python
90
+ from detecte import verify_webhook, WebhookVerificationError
91
+
92
+ try:
93
+ verify_webhook(
94
+ payload=raw_body,
95
+ signature=request.headers["Detecte-Signature"],
96
+ secret=os.environ["DETECTE_WEBHOOK_SECRET"],
97
+ )
98
+ except WebhookVerificationError:
99
+ return Response(status=401)
100
+ ```
101
+
102
+ ## LangChain integration
103
+
104
+ ```python
105
+ from detecte import Detecte
106
+ from detecte.integrations.langchain import DetecteCallbackHandler
107
+
108
+ detecte = Detecte()
109
+ handler = DetecteCallbackHandler(detecte=detecte, agent_id="agent_xxx")
110
+ executor = AgentExecutor.from_agent_and_tools(
111
+ agent=...,
112
+ tools=...,
113
+ callbacks=[handler],
114
+ )
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT — copyright Detecte, Inc.
@@ -0,0 +1,13 @@
1
+ detecte/__init__.py,sha256=KkSHvMuIBE1wqYy8XC1dx_wRWVvksABJ8TqYC-xySfM,1269
2
+ detecte/client.py,sha256=Jvk_hubGS7qAV7C05j5IIHYj8ujt9kSdlKOWk1c9LWc,11715
3
+ detecte/errors.py,sha256=cvEJFG-bammMVBosvtP1i0upMPzEn7izPRKkCmj0KHc,1435
4
+ detecte/http.py,sha256=KP9G30rxArrveDYs99S5Mt4U050pRajKyWL_KZ21_TA,5694
5
+ detecte/types.py,sha256=OgbXQJCvtm2xEX3tNwfWsoARwHEqLRy5OaKRIfoUCYE,2180
6
+ detecte/webhooks.py,sha256=n1-hjRGlw-pb5TFrADiJE4Kw9guwqzpQFCEyOqq41hg,1799
7
+ detecte/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ detecte/integrations/langchain.py,sha256=FZFx5x2mWz2sapG0PG52BBsjYC0A2BN_2nGkTjzFXpQ,2249
9
+ detecte/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ detecte-0.1.1.dist-info/METADATA,sha256=yW_iEbxJW4nRJYnRVlgQkG7-GXgWrfQDD3zWfsxp0vQ,3377
11
+ detecte-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
12
+ detecte-0.1.1.dist-info/licenses/LICENSE,sha256=XeZRBlAeHre83Sl__RpPqjXTWjpd9WLk2MmozKbTSZ8,1070
13
+ detecte-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Detecte, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.