detecte 0.1.1__tar.gz
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-0.1.1/.github/workflows/release.yml +18 -0
- detecte-0.1.1/.github/workflows/test.yml +15 -0
- detecte-0.1.1/.gitignore +16 -0
- detecte-0.1.1/LICENSE +21 -0
- detecte-0.1.1/PKG-INFO +119 -0
- detecte-0.1.1/README.md +85 -0
- detecte-0.1.1/pyproject.toml +46 -0
- detecte-0.1.1/src/detecte/__init__.py +62 -0
- detecte-0.1.1/src/detecte/client.py +332 -0
- detecte-0.1.1/src/detecte/errors.py +65 -0
- detecte-0.1.1/src/detecte/http.py +156 -0
- detecte-0.1.1/src/detecte/integrations/__init__.py +0 -0
- detecte-0.1.1/src/detecte/integrations/langchain.py +68 -0
- detecte-0.1.1/src/detecte/resources/__init__.py +0 -0
- detecte-0.1.1/src/detecte/types.py +86 -0
- detecte-0.1.1/src/detecte/webhooks.py +64 -0
- detecte-0.1.1/tests/test_client.py +80 -0
- detecte-0.1.1/tests/test_webhooks.py +53 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Release to PyPI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ["v*"]
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
id-token: write
|
|
8
|
+
jobs:
|
|
9
|
+
release:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with: { python-version: "3.12" }
|
|
16
|
+
- run: python -m pip install --upgrade pip build
|
|
17
|
+
- run: python -m build
|
|
18
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
on:
|
|
3
|
+
push: { branches: [main] }
|
|
4
|
+
pull_request:
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
strategy:
|
|
9
|
+
matrix: { python-version: ["3.9", "3.10", "3.11", "3.12"] }
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-python@v5
|
|
13
|
+
with: { python-version: ${{ matrix.python-version }} }
|
|
14
|
+
- run: pip install -e ".[dev]"
|
|
15
|
+
- run: python -m pytest -q
|
detecte-0.1.1/.gitignore
ADDED
detecte-0.1.1/LICENSE
ADDED
|
@@ -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.
|
detecte-0.1.1/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/detecte/)
|
|
38
|
+
[](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.
|
detecte-0.1.1/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# detecte (Python)
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/detecte/)
|
|
4
|
+
[](https://pypi.org/project/detecte/)
|
|
5
|
+
|
|
6
|
+
Runtime security for AI agents. The Python SDK for [detecte.xyz](https://detecte.xyz) — a one-to-one
|
|
7
|
+
companion to [`@detecte/sdk`](https://www.npmjs.com/package/@detecte/sdk).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install detecte
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
from detecte import Detecte
|
|
20
|
+
|
|
21
|
+
detecte = Detecte(api_key=os.environ["DETECTE_API_KEY"])
|
|
22
|
+
|
|
23
|
+
decision = detecte.verify(
|
|
24
|
+
agent="support_bot",
|
|
25
|
+
action="refund_order",
|
|
26
|
+
params={"order_id": "ord_8821", "amount": 49.99},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if not decision.allowed:
|
|
30
|
+
raise RuntimeError(f"Blocked: {decision.reason}")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Async
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from detecte import AsyncDetecte
|
|
37
|
+
|
|
38
|
+
async def main():
|
|
39
|
+
detecte = AsyncDetecte(api_key="sk_test_...")
|
|
40
|
+
decision = await detecte.verify(agent="bot", action="x")
|
|
41
|
+
print(decision.allowed)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Resources
|
|
45
|
+
|
|
46
|
+
- `detecte.agents.list() / .get(id) / .create(...)`
|
|
47
|
+
- `detecte.policies.list() / .create(...) / .dry_run(policy=..., sample_size=1000)`
|
|
48
|
+
- `detecte.incidents.list() / .resolve(id)`
|
|
49
|
+
- `detecte.audit.list(...)`
|
|
50
|
+
- `detecte.approvals.get(decision_id) / .wait(decision_id)`
|
|
51
|
+
- `detecte.scans.run(agent=..., system_prompt=...)`
|
|
52
|
+
|
|
53
|
+
## Webhooks
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from detecte import verify_webhook, WebhookVerificationError
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
verify_webhook(
|
|
60
|
+
payload=raw_body,
|
|
61
|
+
signature=request.headers["Detecte-Signature"],
|
|
62
|
+
secret=os.environ["DETECTE_WEBHOOK_SECRET"],
|
|
63
|
+
)
|
|
64
|
+
except WebhookVerificationError:
|
|
65
|
+
return Response(status=401)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## LangChain integration
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from detecte import Detecte
|
|
72
|
+
from detecte.integrations.langchain import DetecteCallbackHandler
|
|
73
|
+
|
|
74
|
+
detecte = Detecte()
|
|
75
|
+
handler = DetecteCallbackHandler(detecte=detecte, agent_id="agent_xxx")
|
|
76
|
+
executor = AgentExecutor.from_agent_and_tools(
|
|
77
|
+
agent=...,
|
|
78
|
+
tools=...,
|
|
79
|
+
callbacks=[handler],
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT — copyright Detecte, Inc.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "detecte"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Runtime security for AI agents — Python SDK for detecte.xyz"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Detecte" }]
|
|
13
|
+
keywords = ["ai", "agents", "security", "runtime", "policy", "kya", "llm"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Topic :: Security",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.25",
|
|
28
|
+
"pydantic>=2.0",
|
|
29
|
+
"typing_extensions>=4.5",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21", "ruff>=0.5", "mypy>=1.10"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://detecte.xyz"
|
|
37
|
+
Documentation = "https://docs.detecte.xyz"
|
|
38
|
+
Repository = "https://github.com/detecte-xyz/detecte-python"
|
|
39
|
+
Issues = "https://github.com/detecte-xyz/detecte-python/issues"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/detecte"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|
|
@@ -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)
|
|
@@ -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"
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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,80 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
import respx
|
|
4
|
+
|
|
5
|
+
from detecte import Detecte, AsyncDetecte
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@respx.mock
|
|
9
|
+
def test_verify_allowed():
|
|
10
|
+
respx.post("https://api.detecte.xyz/v1/verify").mock(
|
|
11
|
+
return_value=httpx.Response(
|
|
12
|
+
200,
|
|
13
|
+
json={
|
|
14
|
+
"id": "dec_1",
|
|
15
|
+
"allowed": True,
|
|
16
|
+
"status": "allowed",
|
|
17
|
+
"reason": None,
|
|
18
|
+
"policies_evaluated": [],
|
|
19
|
+
"risk_delta": 1,
|
|
20
|
+
"metadata": {"latency_ms": 12},
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
d = Detecte(api_key="sk_test_x")
|
|
25
|
+
decision = d.verify(agent="bot", action="x")
|
|
26
|
+
assert decision.allowed
|
|
27
|
+
assert decision.id == "dec_1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@respx.mock
|
|
31
|
+
def test_verify_blocked():
|
|
32
|
+
respx.post("https://api.detecte.xyz/v1/verify").mock(
|
|
33
|
+
return_value=httpx.Response(
|
|
34
|
+
200,
|
|
35
|
+
json={
|
|
36
|
+
"id": "dec_2",
|
|
37
|
+
"allowed": False,
|
|
38
|
+
"status": "blocked",
|
|
39
|
+
"reason": "policy violation",
|
|
40
|
+
"policies_evaluated": [{"id": "pol_1", "name": "amount_cap", "result": "blocked"}],
|
|
41
|
+
"risk_delta": 0,
|
|
42
|
+
"metadata": {"latency_ms": 8},
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
d = Detecte(api_key="sk_test_x")
|
|
47
|
+
decision = d.verify(agent="bot", action="refund", params={"amount": 9999})
|
|
48
|
+
assert not decision.allowed
|
|
49
|
+
assert decision.policies_evaluated[0].name == "amount_cap"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@respx.mock
|
|
53
|
+
def test_failsafe_open_on_network_error():
|
|
54
|
+
respx.post("https://api.detecte.xyz/v1/verify").mock(side_effect=httpx.ConnectError("boom"))
|
|
55
|
+
d = Detecte(api_key="sk_test_x", retries=0, failsafe="fail_open")
|
|
56
|
+
decision = d.verify(agent="bot", action="x")
|
|
57
|
+
assert decision.allowed is True
|
|
58
|
+
assert "fail_open" in (decision.reason or "")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
@respx.mock
|
|
63
|
+
async def test_async_client_verify():
|
|
64
|
+
respx.post("https://api.detecte.xyz/v1/verify").mock(
|
|
65
|
+
return_value=httpx.Response(
|
|
66
|
+
200,
|
|
67
|
+
json={
|
|
68
|
+
"id": "dec_3",
|
|
69
|
+
"allowed": True,
|
|
70
|
+
"status": "allowed",
|
|
71
|
+
"reason": None,
|
|
72
|
+
"policies_evaluated": [],
|
|
73
|
+
"risk_delta": 0,
|
|
74
|
+
"metadata": {"latency_ms": 5},
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
d = AsyncDetecte(api_key="sk_test_x")
|
|
79
|
+
decision = await d.verify(agent="bot", action="x")
|
|
80
|
+
assert decision.allowed
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from detecte.webhooks import (
|
|
8
|
+
WebhookVerificationError,
|
|
9
|
+
is_valid_webhook,
|
|
10
|
+
verify_webhook,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sign(payload: str, secret: str, ts: int) -> str:
|
|
15
|
+
signed = f"{ts}.{payload}".encode("utf-8")
|
|
16
|
+
sig = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
|
|
17
|
+
return f"t={ts},v1={sig}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_valid_signature_passes():
|
|
21
|
+
ts = int(time.time())
|
|
22
|
+
payload = '{"hi":1}'
|
|
23
|
+
secret = "whsec_test"
|
|
24
|
+
verify_webhook(payload=payload, signature=_sign(payload, secret, ts), secret=secret)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_tampered_payload_fails():
|
|
28
|
+
ts = int(time.time())
|
|
29
|
+
secret = "whsec_test"
|
|
30
|
+
sig = _sign('{"hi":1}', secret, ts)
|
|
31
|
+
with pytest.raises(WebhookVerificationError):
|
|
32
|
+
verify_webhook(payload='{"hi":2}', signature=sig, secret=secret)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_old_timestamp_fails():
|
|
36
|
+
secret = "whsec_test"
|
|
37
|
+
payload = '{"hi":1}'
|
|
38
|
+
ts = int(time.time()) - 1000
|
|
39
|
+
with pytest.raises(WebhookVerificationError, match="tolerance"):
|
|
40
|
+
verify_webhook(payload=payload, signature=_sign(payload, secret, ts), secret=secret)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_is_valid_webhook_returns_bool():
|
|
44
|
+
ts = int(time.time())
|
|
45
|
+
payload = "abc"
|
|
46
|
+
secret = "whsec_test"
|
|
47
|
+
assert is_valid_webhook(payload=payload, signature=_sign(payload, secret, ts), secret=secret)
|
|
48
|
+
assert not is_valid_webhook(payload="abd", signature=_sign(payload, secret, ts), secret=secret)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_malformed_header_fails():
|
|
52
|
+
with pytest.raises(WebhookVerificationError):
|
|
53
|
+
verify_webhook(payload="x", signature="not-a-valid-header", secret="s")
|