underwrit-client 0.3.0__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.
- underwrit_client-0.3.0/PKG-INFO +55 -0
- underwrit_client-0.3.0/README.md +43 -0
- underwrit_client-0.3.0/pyproject.toml +27 -0
- underwrit_client-0.3.0/setup.cfg +4 -0
- underwrit_client-0.3.0/underwrit_client/__init__.py +265 -0
- underwrit_client-0.3.0/underwrit_client/integrations/__init__.py +8 -0
- underwrit_client-0.3.0/underwrit_client/integrations/claude_code.py +102 -0
- underwrit_client-0.3.0/underwrit_client/integrations/google_adk.py +47 -0
- underwrit_client-0.3.0/underwrit_client/integrations/http_interceptor.py +65 -0
- underwrit_client-0.3.0/underwrit_client/integrations/langgraph.py +33 -0
- underwrit_client-0.3.0/underwrit_client/integrations/openai_agents.py +42 -0
- underwrit_client-0.3.0/underwrit_client/integrations/otel.py +72 -0
- underwrit_client-0.3.0/underwrit_client/task.py +53 -0
- underwrit_client-0.3.0/underwrit_client.egg-info/PKG-INFO +55 -0
- underwrit_client-0.3.0/underwrit_client.egg-info/SOURCES.txt +16 -0
- underwrit_client-0.3.0/underwrit_client.egg-info/dependency_links.txt +1 -0
- underwrit_client-0.3.0/underwrit_client.egg-info/entry_points.txt +3 -0
- underwrit_client-0.3.0/underwrit_client.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: underwrit-client
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Client for an Underwrit data plane: decide, claim, record, and produce evidence for what an agent did.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/underwrit-io/dataplane
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Topic :: Security
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# underwrit-client
|
|
14
|
+
|
|
15
|
+
Integrate an agent runtime with an Underwrit data plane. Standard library only.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
from underwrit_client import Underwrit, Held
|
|
20
|
+
|
|
21
|
+
underwrit = Underwrit("http://127.0.0.1:8787", token=os.environ["UNDERWRIT_AGENT_TOKEN"])
|
|
22
|
+
|
|
23
|
+
with underwrit.session(agent="ops-assistant", environment="production",
|
|
24
|
+
intent="Restart api during incident 4417") as s:
|
|
25
|
+
logs = s.call("k8s/get_k8s_logs", get_logs, pod="api-1")
|
|
26
|
+
try:
|
|
27
|
+
s.call("k8s/rollout_restart", restart, deployment="api", namespace="prod")
|
|
28
|
+
except Held as h:
|
|
29
|
+
park(h.decision["id"]) # a person answers in the console, then:
|
|
30
|
+
s.resume(h.decision["id"], restart, deployment="api", namespace="prod",
|
|
31
|
+
verify=lambda r: {"status": "passed", "checks": [{"name": "rollout", "ok": True}]})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `call()` decides, runs the function, and records the outcome. It raises `Held` when the data
|
|
35
|
+
plane says `act: "hold"` — enforcement on, verdict held or denied. In shadow mode nothing is
|
|
36
|
+
raised and the verdict is still recorded.
|
|
37
|
+
- `resume()` claims an approval with the same arguments immediately before executing. The claim is
|
|
38
|
+
refused if the arguments or the stated preconditions changed, the approval expired, the policy
|
|
39
|
+
now refuses it, or it was already used.
|
|
40
|
+
- A `TimeoutError` from the wrapped function is recorded as `uncertain`, and the data plane will not
|
|
41
|
+
let a retry overwrite that without `reconcile=True`.
|
|
42
|
+
- `verify=` records what was observed afterwards, separately from whether the call succeeded.
|
|
43
|
+
|
|
44
|
+
The full routes are in the Underwrit README; this client adds nothing the service does not do.
|
|
45
|
+
|
|
46
|
+
## Also
|
|
47
|
+
|
|
48
|
+
- `open_session(..., task_policy={...})` attaches a t=0 task policy; `guarded(session, tool)` wraps a
|
|
49
|
+
function so every call goes through the session.
|
|
50
|
+
- `decide(..., signals={"taint": True, "risk": 80, "source": "my-classifier"})` lets an external
|
|
51
|
+
classifier raise taint or risk; it can never lower them.
|
|
52
|
+
- `receipt(seq)` returns the per-entry receipt (leaf index, tree size, root, inclusion proof, signed
|
|
53
|
+
checkpoint) once a checkpoint covers the entry; `Decision["receipt"]` carries the chain position now.
|
|
54
|
+
- 429 and 503 are retried with `Retry-After` (three times by default, capped at thirty seconds).
|
|
55
|
+
- `Held` exposes `id`, `verdict`, `budgeted`, `duplicate`, `quorum` and `risk`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# underwrit-client
|
|
2
|
+
|
|
3
|
+
Integrate an agent runtime with an Underwrit data plane. Standard library only.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import os
|
|
7
|
+
from underwrit_client import Underwrit, Held
|
|
8
|
+
|
|
9
|
+
underwrit = Underwrit("http://127.0.0.1:8787", token=os.environ["UNDERWRIT_AGENT_TOKEN"])
|
|
10
|
+
|
|
11
|
+
with underwrit.session(agent="ops-assistant", environment="production",
|
|
12
|
+
intent="Restart api during incident 4417") as s:
|
|
13
|
+
logs = s.call("k8s/get_k8s_logs", get_logs, pod="api-1")
|
|
14
|
+
try:
|
|
15
|
+
s.call("k8s/rollout_restart", restart, deployment="api", namespace="prod")
|
|
16
|
+
except Held as h:
|
|
17
|
+
park(h.decision["id"]) # a person answers in the console, then:
|
|
18
|
+
s.resume(h.decision["id"], restart, deployment="api", namespace="prod",
|
|
19
|
+
verify=lambda r: {"status": "passed", "checks": [{"name": "rollout", "ok": True}]})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- `call()` decides, runs the function, and records the outcome. It raises `Held` when the data
|
|
23
|
+
plane says `act: "hold"` — enforcement on, verdict held or denied. In shadow mode nothing is
|
|
24
|
+
raised and the verdict is still recorded.
|
|
25
|
+
- `resume()` claims an approval with the same arguments immediately before executing. The claim is
|
|
26
|
+
refused if the arguments or the stated preconditions changed, the approval expired, the policy
|
|
27
|
+
now refuses it, or it was already used.
|
|
28
|
+
- A `TimeoutError` from the wrapped function is recorded as `uncertain`, and the data plane will not
|
|
29
|
+
let a retry overwrite that without `reconcile=True`.
|
|
30
|
+
- `verify=` records what was observed afterwards, separately from whether the call succeeded.
|
|
31
|
+
|
|
32
|
+
The full routes are in the Underwrit README; this client adds nothing the service does not do.
|
|
33
|
+
|
|
34
|
+
## Also
|
|
35
|
+
|
|
36
|
+
- `open_session(..., task_policy={...})` attaches a t=0 task policy; `guarded(session, tool)` wraps a
|
|
37
|
+
function so every call goes through the session.
|
|
38
|
+
- `decide(..., signals={"taint": True, "risk": 80, "source": "my-classifier"})` lets an external
|
|
39
|
+
classifier raise taint or risk; it can never lower them.
|
|
40
|
+
- `receipt(seq)` returns the per-entry receipt (leaf index, tree size, root, inclusion proof, signed
|
|
41
|
+
checkpoint) once a checkpoint covers the entry; `Decision["receipt"]` carries the chain position now.
|
|
42
|
+
- 429 and 503 are retried with `Retry-After` (three times by default, capped at thirty seconds).
|
|
43
|
+
- `Held` exposes `id`, `verdict`, `budgeted`, `duplicate`, `quorum` and `risk`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "underwrit-client"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Client for an Underwrit data plane: decide, claim, record, and produce evidence for what an agent did."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Topic :: Security",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/underwrit-io/dataplane"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
underwrit-hook = "underwrit_client.integrations.claude_code:main"
|
|
24
|
+
underwrit-interceptor = "underwrit_client.integrations.http_interceptor:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
packages = ["underwrit_client", "underwrit_client.integrations"]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""underwrit-client: integrate an agent runtime with an Underwrit data plane in under twenty lines.
|
|
2
|
+
|
|
3
|
+
from underwrit_client import Underwrit, Held
|
|
4
|
+
|
|
5
|
+
underwrit = Underwrit("http://127.0.0.1:8787", token=os.environ["UNDERWRIT_AGENT_TOKEN"])
|
|
6
|
+
with underwrit.session(agent="ops-assistant", environment="production",
|
|
7
|
+
intent="Restart api during incident 4417") as s:
|
|
8
|
+
logs = s.call("k8s/get_k8s_logs", get_logs, pod="api-1") # decided, run, recorded
|
|
9
|
+
try:
|
|
10
|
+
s.call("k8s/rollout_restart", restart, deployment="api") # held? raises Held
|
|
11
|
+
except Held as h:
|
|
12
|
+
print("waiting on a person:", h.decision["id"])
|
|
13
|
+
|
|
14
|
+
Standard library only. Every method maps to one data-plane route, and the shapes returned are the
|
|
15
|
+
service's own — nothing is reinterpreted here.
|
|
16
|
+
|
|
17
|
+
**`Held` is the contract.** When the data plane says `act: "hold"`, the wrapped call is not made
|
|
18
|
+
and `Held` is raised with the decision attached, so a runtime can park the task, show the decision
|
|
19
|
+
id, and come back with `claim()` once a person has answered. In shadow mode `act` is `"proceed"`
|
|
20
|
+
and nothing is raised; the verdict is still recorded.
|
|
21
|
+
|
|
22
|
+
**A claim before an approved execution.** `claim()` presents the same arguments again and is told
|
|
23
|
+
whether the approval still holds — same arguments, same relevant state, still within its validity
|
|
24
|
+
window, not already used. Use it immediately before executing anything that was held.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import functools
|
|
30
|
+
import json
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from typing import Any, Callable
|
|
35
|
+
|
|
36
|
+
__version__ = "0.3.0"
|
|
37
|
+
__all__ = ["Underwrit", "Session", "Held", "UnderwritError", "guarded"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UnderwritError(Exception):
|
|
41
|
+
"""The data plane refused or failed a request. `status` is the HTTP status."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, status: int, message: str):
|
|
44
|
+
super().__init__(f"{status}: {message}")
|
|
45
|
+
self.status = status
|
|
46
|
+
self.message = message
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Held(Exception):
|
|
50
|
+
"""The action must not proceed until a person answers, or was refused.
|
|
51
|
+
|
|
52
|
+
`decision` is the data plane's response — its `id` is what to resolve, and `reasons` say why.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, decision: dict, reason: str = ""):
|
|
56
|
+
rs = decision.get("reasons") or ([reason] if reason else [])
|
|
57
|
+
super().__init__(f"{decision.get('verdict', 'held')}: {'; '.join(rs) or 'held for a person'}")
|
|
58
|
+
self.decision = decision
|
|
59
|
+
self.reason = reason or (rs[0] if rs else "")
|
|
60
|
+
# What the data plane said beside the verdict, so a runtime can act on it without parsing.
|
|
61
|
+
self.id: str = str(decision.get("id") or "")
|
|
62
|
+
self.verdict: str = str(decision.get("verdict") or "")
|
|
63
|
+
self.budgeted: bool = bool(decision.get("budgeted"))
|
|
64
|
+
self.duplicate: bool = bool(decision.get("duplicate"))
|
|
65
|
+
self.quorum: dict | None = decision.get("quorum") if isinstance(decision.get("quorum"), dict) else None
|
|
66
|
+
self.risk: int = int(decision.get("risk") or 0)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Underwrit:
|
|
70
|
+
def __init__(self, url: str, token: str, *, timeout: float = 10.0, retries: int = 3,
|
|
71
|
+
max_retry_wait: float = 30.0, sleep: Callable[[float], None] = time.sleep):
|
|
72
|
+
self.url = url.rstrip("/")
|
|
73
|
+
self.token = token
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.retries = retries
|
|
76
|
+
self.max_retry_wait = max_retry_wait
|
|
77
|
+
self._sleep = sleep
|
|
78
|
+
|
|
79
|
+
# -- transport -------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _call(self, method: str, path: str, body: dict | None = None) -> dict:
|
|
82
|
+
"""One request. 429 and 503 are retried, honouring Retry-After, up to `retries` times and
|
|
83
|
+
never longer than `max_retry_wait` per wait; every other error is the caller's."""
|
|
84
|
+
attempt = 0
|
|
85
|
+
while True:
|
|
86
|
+
req = urllib.request.Request(
|
|
87
|
+
f"{self.url}{path}",
|
|
88
|
+
data=json.dumps(body).encode("utf-8") if body is not None else None,
|
|
89
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"},
|
|
90
|
+
method=method,
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
94
|
+
return json.loads(r.read().decode("utf-8") or "{}")
|
|
95
|
+
except urllib.error.HTTPError as e:
|
|
96
|
+
try:
|
|
97
|
+
payload = json.loads(e.read().decode("utf-8") or "{}")
|
|
98
|
+
except ValueError:
|
|
99
|
+
payload = {}
|
|
100
|
+
if e.code in (429, 503) and attempt < self.retries:
|
|
101
|
+
attempt += 1
|
|
102
|
+
try:
|
|
103
|
+
wait = float(e.headers.get("Retry-After") or payload.get("retryAfterSeconds") or 0)
|
|
104
|
+
except (TypeError, ValueError):
|
|
105
|
+
wait = 0.0
|
|
106
|
+
self._sleep(min(self.max_retry_wait, wait or min(2.0 ** attempt, self.max_retry_wait)))
|
|
107
|
+
continue
|
|
108
|
+
raise UnderwritError(e.code, payload.get("error", "") or e.reason) from None
|
|
109
|
+
except urllib.error.URLError as e:
|
|
110
|
+
raise UnderwritError(0, f"cannot reach {self.url}: {e.reason}") from None
|
|
111
|
+
|
|
112
|
+
# -- the routes ------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def open_session(self, *, session: str = "", agent: str = "", environment: str = "",
|
|
115
|
+
intent: str = "", actor: str = "", task_policy: dict | None = None) -> "Session":
|
|
116
|
+
s = self._call("POST", "/v1/sessions", {"session": session, "agent": agent,
|
|
117
|
+
"environment": environment, "intent": intent,
|
|
118
|
+
"actor": actor,
|
|
119
|
+
**({"taskPolicy": task_policy} if task_policy else {})})
|
|
120
|
+
return Session(self, s["id"], s)
|
|
121
|
+
|
|
122
|
+
session = open_session
|
|
123
|
+
|
|
124
|
+
def decide(self, session: str, tool: str, arguments: dict | None = None, *,
|
|
125
|
+
preconditions: Any = None, idempotency_key: str = "", signals: dict | None = None) -> dict:
|
|
126
|
+
"""`signals` carries an external classifier's verdict ({"taint": True, "risk": 80,
|
|
127
|
+
"source": "my-classifier"}); it can raise taint and risk, never lower them."""
|
|
128
|
+
body: dict = {"session": session, "tool": tool, "arguments": arguments or {}}
|
|
129
|
+
if preconditions is not None:
|
|
130
|
+
body["preconditions"] = preconditions
|
|
131
|
+
if idempotency_key:
|
|
132
|
+
body["idempotencyKey"] = idempotency_key
|
|
133
|
+
if signals:
|
|
134
|
+
body["signals"] = signals
|
|
135
|
+
return self._call("POST", "/v1/decide", body)
|
|
136
|
+
|
|
137
|
+
def receipt(self, seq: int) -> dict:
|
|
138
|
+
"""The per-entry receipt: leaf index, tree size, root, inclusion proof and the signed
|
|
139
|
+
checkpoint. `pending: True` until a checkpoint covers the entry."""
|
|
140
|
+
return self._call("GET", f"/v1/receipts/{int(seq)}")
|
|
141
|
+
|
|
142
|
+
def pending(self, limit: int = 100, offset: int = 0) -> dict:
|
|
143
|
+
return self._call("GET", f"/v1/pending?limit={int(limit)}&offset={int(offset)}")
|
|
144
|
+
|
|
145
|
+
def claim(self, decision_id: str, arguments: dict | None = None, *,
|
|
146
|
+
preconditions: Any = None) -> dict:
|
|
147
|
+
body: dict = {}
|
|
148
|
+
if arguments is not None:
|
|
149
|
+
body["arguments"] = arguments
|
|
150
|
+
if preconditions is not None:
|
|
151
|
+
body["preconditions"] = preconditions
|
|
152
|
+
return self._call("POST", f"/v1/decisions/{decision_id}/claim", body)
|
|
153
|
+
|
|
154
|
+
def outcome(self, decision_id: str, status: str, detail: str = "", *,
|
|
155
|
+
verification: dict | None = None, reconcile: bool = False) -> dict:
|
|
156
|
+
body: dict = {"decision": decision_id, "status": status, "detail": detail}
|
|
157
|
+
if verification:
|
|
158
|
+
body["verification"] = verification
|
|
159
|
+
if reconcile:
|
|
160
|
+
body["reconcile"] = True
|
|
161
|
+
return self._call("POST", "/v1/outcome", body)
|
|
162
|
+
|
|
163
|
+
def decision(self, decision_id: str) -> dict:
|
|
164
|
+
return self._call("GET", f"/v1/decisions/{decision_id}")
|
|
165
|
+
|
|
166
|
+
def close(self, session: str, status: str = "succeeded") -> dict:
|
|
167
|
+
return self._call("POST", f"/v1/sessions/{session}/close", {"status": status})
|
|
168
|
+
|
|
169
|
+
def evidence(self, session: str) -> dict:
|
|
170
|
+
return self._call("GET", f"/v1/evidence/{session}")
|
|
171
|
+
|
|
172
|
+
def health(self) -> dict:
|
|
173
|
+
return self._call("GET", "/v1/health")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Session:
|
|
177
|
+
"""One agent session. A context manager: closes on exit, `failed` if an exception escaped."""
|
|
178
|
+
|
|
179
|
+
def __init__(self, underwrit: Underwrit, session_id: str, meta: dict | None = None):
|
|
180
|
+
self.underwrit = underwrit
|
|
181
|
+
self.id = session_id
|
|
182
|
+
self.meta = meta or {}
|
|
183
|
+
|
|
184
|
+
def __enter__(self) -> "Session":
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
188
|
+
try:
|
|
189
|
+
self.underwrit.close(self.id, "failed" if exc_type else "succeeded")
|
|
190
|
+
except UnderwritError:
|
|
191
|
+
pass
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def decide(self, tool: str, arguments: dict | None = None, **kw) -> dict:
|
|
195
|
+
return self.underwrit.decide(self.id, tool, arguments, **kw)
|
|
196
|
+
|
|
197
|
+
def outcome(self, decision_id: str, status: str, detail: str = "", **kw) -> dict:
|
|
198
|
+
return self.underwrit.outcome(decision_id, status, detail, **kw)
|
|
199
|
+
|
|
200
|
+
def claim(self, decision_id: str, arguments: dict | None = None, **kw) -> dict:
|
|
201
|
+
return self.underwrit.claim(decision_id, arguments, **kw)
|
|
202
|
+
|
|
203
|
+
def call(self, tool: str, fn: Callable[..., Any], /, *args, preconditions: Any = None,
|
|
204
|
+
idempotency_key: str = "", verify: Callable[[Any], dict] | None = None,
|
|
205
|
+
signals: dict | None = None, **kwargs) -> Any:
|
|
206
|
+
"""Decide, then run `fn(*args, **kwargs)`, then report what happened. Raises `Held`.
|
|
207
|
+
|
|
208
|
+
The keyword arguments are the action's arguments as the data plane sees them; positional
|
|
209
|
+
arguments are passed through to `fn` but are not part of the decision.
|
|
210
|
+
"""
|
|
211
|
+
d = self.decide(tool, kwargs, preconditions=preconditions, idempotency_key=idempotency_key, signals=signals)
|
|
212
|
+
if d.get("act") == "hold":
|
|
213
|
+
raise Held(d)
|
|
214
|
+
return self._run(d["id"], fn, args, kwargs, verify)
|
|
215
|
+
|
|
216
|
+
def resume(self, decision_id: str, fn: Callable[..., Any], /, *args,
|
|
217
|
+
preconditions: Any = None, verify: Callable[[Any], dict] | None = None,
|
|
218
|
+
**kwargs) -> Any:
|
|
219
|
+
"""After a person answered: claim the approval with the same arguments, then run."""
|
|
220
|
+
c = self.claim(decision_id, kwargs, preconditions=preconditions)
|
|
221
|
+
if not c.get("ok") and c.get("act") == "hold":
|
|
222
|
+
raise Held(c.get("decision") or {"id": decision_id}, c.get("reason", ""))
|
|
223
|
+
return self._run(decision_id, fn, args, kwargs, verify)
|
|
224
|
+
|
|
225
|
+
def _run(self, decision_id, fn, args, kwargs, verify):
|
|
226
|
+
try:
|
|
227
|
+
result = fn(*args, **kwargs)
|
|
228
|
+
except TimeoutError as exc:
|
|
229
|
+
# A timeout may mean the action completed and the response was lost. Recorded as
|
|
230
|
+
# uncertain, which the data plane refuses to let a retry overwrite silently.
|
|
231
|
+
self.outcome(decision_id, "uncertain", str(exc)[:2000])
|
|
232
|
+
raise
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
self.outcome(decision_id, "failed", str(exc)[:2000])
|
|
235
|
+
raise
|
|
236
|
+
detail = result if isinstance(result, str) else json.dumps(result, default=str)[:2000]
|
|
237
|
+
ver = None
|
|
238
|
+
if verify is not None:
|
|
239
|
+
try:
|
|
240
|
+
ver = verify(result)
|
|
241
|
+
except Exception as exc: # noqa: BLE001 — a broken check is a failed verification
|
|
242
|
+
ver = {"status": "failed", "summary": f"verification raised {type(exc).__name__}"}
|
|
243
|
+
self.outcome(decision_id, "succeeded", detail, verification=ver)
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def guarded(session: Session, tool: str | None = None, **opts):
|
|
248
|
+
"""Decorate a tool function so every call goes through the session first.
|
|
249
|
+
|
|
250
|
+
@guarded(session, "bank/send_money")
|
|
251
|
+
def send_money(amount, recipient): ...
|
|
252
|
+
|
|
253
|
+
The function must be called with keyword arguments; they are the action's arguments.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def wrap(fn):
|
|
257
|
+
name = tool or fn.__name__
|
|
258
|
+
|
|
259
|
+
@functools.wraps(fn)
|
|
260
|
+
def inner(**kwargs):
|
|
261
|
+
return session.call(name, fn, **opts, **kwargs)
|
|
262
|
+
|
|
263
|
+
return inner
|
|
264
|
+
|
|
265
|
+
return wrap
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Adapters that put an Underwrit decision inside each framework's own pause.
|
|
2
|
+
|
|
3
|
+
Every framework ships a "pause before the tool call"; none ships the decision, the separate
|
|
4
|
+
approver, or the evidence. These adapters are thin on purpose: each maps one framework's hook onto
|
|
5
|
+
`Session.decide` / `Session.claim` / `Session.outcome` and nothing else, so what a framework sees is
|
|
6
|
+
exactly what the data plane decided. No framework is imported at module load; each adapter imports
|
|
7
|
+
its framework lazily or not at all.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Claude Code hooks: PreToolUse decides, PostToolUse records the outcome.
|
|
2
|
+
|
|
3
|
+
# .claude/settings.json
|
|
4
|
+
{"hooks": {
|
|
5
|
+
"PreToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "underwrit-hook pre"}]}],
|
|
6
|
+
"PostToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "underwrit-hook post"}]}]
|
|
7
|
+
}}
|
|
8
|
+
|
|
9
|
+
The hook reads Claude Code's JSON on stdin (session_id, tool_name, tool_input, tool_response) and
|
|
10
|
+
answers with a `permissionDecision`: `allow` for allow and allow_recorded, `ask` for a hold, `deny`
|
|
11
|
+
for a denial. `ask` hands the decision to the person at the terminal — Underwrit records that a hold was
|
|
12
|
+
raised; who answered it is that person, and the PostToolUse hook records what happened.
|
|
13
|
+
|
|
14
|
+
Environment: UNDERWRIT_URL, UNDERWRIT_AGENT_TOKEN, optionally UNDERWRIT_ENVIRONMENT and UNDERWRIT_AGENT. The Claude
|
|
15
|
+
Code session id becomes the Underwrit session id, so one conversation is one evidence subject.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from .. import Underwrit, UnderwritError
|
|
25
|
+
|
|
26
|
+
_PERMISSION = {"allow": "allow", "allow_recorded": "allow", "require_human": "ask", "deny": "deny"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _client() -> Underwrit:
|
|
30
|
+
return Underwrit(os.environ.get("UNDERWRIT_URL", "http://127.0.0.1:8787"), os.environ.get("UNDERWRIT_AGENT_TOKEN", ""))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _state_path(session_id: str) -> str:
|
|
34
|
+
return os.path.join(os.environ.get("TMPDIR", "/tmp"), f"underwrit-claude-{session_id}.json")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pre(event: dict) -> dict:
|
|
38
|
+
"""Decide. Returns the hook's JSON output."""
|
|
39
|
+
sid = f"claude-{event.get('session_id', 'unknown')}"
|
|
40
|
+
tool = f"claude/{event.get('tool_name', '')}"
|
|
41
|
+
args = event.get("tool_input") if isinstance(event.get("tool_input"), dict) else {"input": event.get("tool_input")}
|
|
42
|
+
w = _client()
|
|
43
|
+
try:
|
|
44
|
+
w.open_session(session=sid, agent=os.environ.get("UNDERWRIT_AGENT", "claude-code"),
|
|
45
|
+
environment=os.environ.get("UNDERWRIT_ENVIRONMENT", "dev"),
|
|
46
|
+
intent=str(event.get("prompt") or "")[:400])
|
|
47
|
+
d = w.decide(sid, tool, args)
|
|
48
|
+
except UnderwritError as exc:
|
|
49
|
+
# The data plane is unreachable or refused: fail the way the operator chose. Default is
|
|
50
|
+
# to ask, which is the safe side of a decision plane that could not decide.
|
|
51
|
+
mode = os.environ.get("UNDERWRIT_HOOK_ON_ERROR", "ask")
|
|
52
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": mode,
|
|
53
|
+
"permissionDecisionReason": f"Underwrit unavailable ({exc.message}); {mode}"}}
|
|
54
|
+
# Remember the decision id for the PostToolUse hook; Claude Code gives both hooks the tool_use_id.
|
|
55
|
+
try:
|
|
56
|
+
with open(_state_path(sid), "a") as f:
|
|
57
|
+
f.write(json.dumps({"tool_use_id": event.get("tool_use_id"), "decision": d["id"]}) + "\n")
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
reason = "; ".join(d.get("reasons") or []) or d["verdict"]
|
|
61
|
+
return {"hookSpecificOutput": {
|
|
62
|
+
"hookEventName": "PreToolUse",
|
|
63
|
+
"permissionDecision": _PERMISSION.get(d["verdict"], "ask"),
|
|
64
|
+
"permissionDecisionReason": f"Underwrit {d['verdict']} (decision {d['id']}, risk {d.get('risk', 0)}): {reason}",
|
|
65
|
+
}}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def post(event: dict) -> dict:
|
|
69
|
+
"""Record the outcome of the call the PreToolUse hook decided."""
|
|
70
|
+
sid = f"claude-{event.get('session_id', 'unknown')}"
|
|
71
|
+
did = None
|
|
72
|
+
try:
|
|
73
|
+
with open(_state_path(sid)) as f:
|
|
74
|
+
for line in f:
|
|
75
|
+
rec = json.loads(line)
|
|
76
|
+
if rec.get("tool_use_id") == event.get("tool_use_id"):
|
|
77
|
+
did = rec["decision"]
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
if not did:
|
|
81
|
+
return {}
|
|
82
|
+
resp = event.get("tool_response")
|
|
83
|
+
detail = resp if isinstance(resp, str) else json.dumps(resp, default=str)
|
|
84
|
+
failed = isinstance(resp, dict) and bool(resp.get("error") or resp.get("is_error"))
|
|
85
|
+
try:
|
|
86
|
+
_client().outcome(did, "failed" if failed else "succeeded", detail[:4000])
|
|
87
|
+
except UnderwritError:
|
|
88
|
+
pass
|
|
89
|
+
return {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv=None) -> int:
|
|
93
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
94
|
+
which = argv[0] if argv else "pre"
|
|
95
|
+
try:
|
|
96
|
+
event = json.load(sys.stdin)
|
|
97
|
+
except ValueError:
|
|
98
|
+
event = {}
|
|
99
|
+
out = pre(event) if which == "pre" else post(event)
|
|
100
|
+
if out:
|
|
101
|
+
sys.stdout.write(json.dumps(out))
|
|
102
|
+
return 0
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Google ADK: a `before_tool_callback` that decides, and a confirmation guard.
|
|
2
|
+
|
|
3
|
+
from underwrit_client.integrations.google_adk import before_tool_callback
|
|
4
|
+
agent = Agent(..., before_tool_callback=before_tool_callback(session))
|
|
5
|
+
|
|
6
|
+
The callback returns a dict to short-circuit the tool (ADK treats a returned dict as the tool's
|
|
7
|
+
result) when Underwrit says hold or deny with enforcement on. The confirmation guard closes the class of
|
|
8
|
+
bug in CVE-2026-18236: before honouring an `adk_request_confirmation` response, it claims the Underwrit
|
|
9
|
+
decision with the arguments actually about to run, so a confirmation for one call cannot be
|
|
10
|
+
replayed against another.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .. import Session
|
|
16
|
+
|
|
17
|
+
_by_tool_call: dict[str, str] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def before_tool_callback(session: Session, *, prefix: str = "adk"):
|
|
21
|
+
def _cb(tool, args, tool_context=None):
|
|
22
|
+
name = getattr(tool, "name", None) or str(tool)
|
|
23
|
+
d = session.decide(f"{prefix}/{name}", dict(args or {}))
|
|
24
|
+
key = getattr(tool_context, "function_call_id", None) or name
|
|
25
|
+
_by_tool_call[key] = d["id"]
|
|
26
|
+
if d.get("act") == "hold":
|
|
27
|
+
return {"error": f"held by Underwrit (decision {d['id']}): " + "; ".join(d.get("reasons") or [])}
|
|
28
|
+
return None
|
|
29
|
+
return _cb
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def after_tool_callback(session: Session):
|
|
33
|
+
def _cb(tool, args, tool_context=None, tool_response=None):
|
|
34
|
+
name = getattr(tool, "name", None) or str(tool)
|
|
35
|
+
key = getattr(tool_context, "function_call_id", None) or name
|
|
36
|
+
did = _by_tool_call.pop(key, None)
|
|
37
|
+
if did:
|
|
38
|
+
failed = isinstance(tool_response, dict) and "error" in tool_response
|
|
39
|
+
session.outcome(did, "failed" if failed else "succeeded", str(tool_response)[:4000])
|
|
40
|
+
return None
|
|
41
|
+
return _cb
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def confirmation_guard(session: Session, decision_id: str, arguments: dict) -> bool:
|
|
45
|
+
"""True only if the approval for `decision_id` still holds for exactly these arguments."""
|
|
46
|
+
c = session.claim(decision_id, arguments)
|
|
47
|
+
return bool(c.get("ok"))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""A pre-call HTTP interceptor for gateways that can call out before forwarding a tool call.
|
|
2
|
+
|
|
3
|
+
Docker MCP Gateway (`--interceptor=before:http:<url>`), LiteLLM custom guardrails, agentgateway
|
|
4
|
+
or Envoy `ext_authz` hops can POST the tool call here and get back allow / deny / hold. It is a
|
|
5
|
+
stdlib HTTP server around one Underwrit session per caller-supplied session id.
|
|
6
|
+
|
|
7
|
+
UNDERWRIT_URL=... UNDERWRIT_AGENT_TOKEN=... python3 -m underwrit_client.integrations.http_interceptor 8790
|
|
8
|
+
|
|
9
|
+
POST / {"session": "...", "tool": "...", "arguments": {...}}
|
|
10
|
+
-> {"allow": bool, "verdict": "...", "decision": "...", "reason": "..."}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
|
|
20
|
+
from .. import Underwrit, UnderwritError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def decide(body: dict) -> dict:
|
|
24
|
+
w = Underwrit(os.environ.get("UNDERWRIT_URL", "http://127.0.0.1:8787"), os.environ.get("UNDERWRIT_AGENT_TOKEN", ""))
|
|
25
|
+
sid = str(body.get("session") or "interceptor")
|
|
26
|
+
tool = str(body.get("tool") or (body.get("params") or {}).get("name") or "")
|
|
27
|
+
args = body.get("arguments") or (body.get("params") or {}).get("arguments") or {}
|
|
28
|
+
try:
|
|
29
|
+
w.open_session(session=sid, agent=os.environ.get("UNDERWRIT_AGENT", "gateway"),
|
|
30
|
+
environment=os.environ.get("UNDERWRIT_ENVIRONMENT", "dev"))
|
|
31
|
+
d = w.decide(sid, tool, args if isinstance(args, dict) else {})
|
|
32
|
+
except UnderwritError as exc:
|
|
33
|
+
allow = os.environ.get("UNDERWRIT_HOOK_ON_ERROR", "deny") == "allow"
|
|
34
|
+
return {"allow": allow, "verdict": "unavailable", "reason": exc.message}
|
|
35
|
+
return {"allow": d.get("act") != "hold", "verdict": d["verdict"], "decision": d["id"],
|
|
36
|
+
"reason": "; ".join(d.get("reasons") or []), "risk": d.get("risk")}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Handler(BaseHTTPRequestHandler):
|
|
40
|
+
def log_message(self, *a):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
def do_POST(self):
|
|
44
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
45
|
+
try:
|
|
46
|
+
body = json.loads(self.rfile.read(n) or b"{}")
|
|
47
|
+
except ValueError:
|
|
48
|
+
body = {}
|
|
49
|
+
out = json.dumps(decide(body)).encode()
|
|
50
|
+
self.send_response(200)
|
|
51
|
+
self.send_header("Content-Type", "application/json")
|
|
52
|
+
self.send_header("Content-Length", str(len(out)))
|
|
53
|
+
self.end_headers()
|
|
54
|
+
self.wfile.write(out)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(argv=None) -> int:
|
|
58
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
59
|
+
port = int(argv[0]) if argv else 8790
|
|
60
|
+
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""LangGraph: wrap a tool so an Underwrit hold becomes an `interrupt()` carrying the decision id.
|
|
2
|
+
|
|
3
|
+
from underwrit_client.integrations.langgraph import guarded_tool
|
|
4
|
+
|
|
5
|
+
send_email = guarded_tool(session, "mail/send_email", send_email_impl)
|
|
6
|
+
|
|
7
|
+
On `require_human` with enforcement on, the wrapper calls LangGraph's `interrupt()` with the
|
|
8
|
+
decision id and reasons; when the graph resumes (after a person answered in the Underwrit console) it
|
|
9
|
+
claims the decision with the same arguments and runs. In shadow mode it runs and records. Without
|
|
10
|
+
LangGraph installed the wrapper raises `Held` instead, so the same code works under any runner.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .. import Held, Session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def guarded_tool(session: Session, tool: str, fn, *, preconditions=None, verify=None):
|
|
19
|
+
def _run(**kwargs):
|
|
20
|
+
d = session.decide(tool, kwargs, preconditions=preconditions)
|
|
21
|
+
if d.get("act") == "hold":
|
|
22
|
+
try:
|
|
23
|
+
from langgraph.types import interrupt # type: ignore
|
|
24
|
+
except ImportError:
|
|
25
|
+
raise Held(d)
|
|
26
|
+
interrupt({"underwrit": {"decision": d["id"], "tool": tool, "reasons": d.get("reasons", []),
|
|
27
|
+
"risk": d.get("risk")}})
|
|
28
|
+
# Resumed: the person answered in Underwrit. Claim with the same arguments before running.
|
|
29
|
+
return session.resume(d["id"], fn, preconditions=preconditions, verify=verify, **kwargs)
|
|
30
|
+
return session._run(d["id"], fn, (), kwargs, verify)
|
|
31
|
+
_run.__name__ = getattr(fn, "__name__", tool)
|
|
32
|
+
_run.__doc__ = getattr(fn, "__doc__", None)
|
|
33
|
+
return _run
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""OpenAI Agents SDK: `needs_approval` from an Underwrit decision, and outcomes from tool results.
|
|
2
|
+
|
|
3
|
+
from underwrit_client.integrations.openai_agents import needs_approval, record_outcome
|
|
4
|
+
|
|
5
|
+
@function_tool(needs_approval=needs_approval(session, "bank/send_money"))
|
|
6
|
+
def send_money(recipient: str, amount: float) -> str: ...
|
|
7
|
+
|
|
8
|
+
`needs_approval` returns True only when Underwrit says `require_human` with enforcement on, so the SDK
|
|
9
|
+
pauses and surfaces a `ToolApprovalItem`; in shadow mode the decision is recorded and the call
|
|
10
|
+
proceeds. The decision id is kept per call id so `record_outcome` can close it. Nothing here
|
|
11
|
+
imports the SDK; the callable signature `(ctx, args, call_id)` is what it expects.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
from .. import Session
|
|
19
|
+
|
|
20
|
+
_by_call: dict[str, str] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def needs_approval(session: Session, tool: str, *, preconditions=None):
|
|
24
|
+
async def _needs(ctx, args, call_id=None):
|
|
25
|
+
arguments = args if isinstance(args, dict) else (json.loads(args) if isinstance(args, str) else {})
|
|
26
|
+
d = session.decide(tool, arguments, preconditions=preconditions, idempotency_key=call_id or "")
|
|
27
|
+
if call_id:
|
|
28
|
+
_by_call[call_id] = d["id"]
|
|
29
|
+
return d.get("act") == "hold"
|
|
30
|
+
return _needs
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def approve(session: Session, call_id: str, arguments: dict | None = None) -> dict:
|
|
34
|
+
"""After a person approved in the SDK, claim the Underwrit decision before the tool actually runs."""
|
|
35
|
+
return session.claim(_by_call[call_id], arguments)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def record_outcome(session: Session, call_id: str, result, *, failed: bool = False) -> None:
|
|
39
|
+
did = _by_call.pop(call_id, None)
|
|
40
|
+
if did:
|
|
41
|
+
detail = result if isinstance(result, str) else json.dumps(result, default=str)
|
|
42
|
+
session.outcome(did, "failed" if failed else "succeeded", detail[:4000])
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""OpenTelemetry: one span per Underwrit decision, with the attributes the GenAI conventions lack.
|
|
2
|
+
|
|
3
|
+
The GenAI semantic conventions carry `gen_ai.tool.name` and `gen_ai.tool.call.id` and nothing for
|
|
4
|
+
a policy decision or a human approval. These are the attributes Underwrit emits, proposed for that gap:
|
|
5
|
+
|
|
6
|
+
underwrit.decision allow | allow_recorded | require_human | deny
|
|
7
|
+
underwrit.decision.id the decision id (joins to the evidence bundle)
|
|
8
|
+
underwrit.policy.version
|
|
9
|
+
underwrit.taint true when the session had consumed untrusted content
|
|
10
|
+
underwrit.authority authorized | untrusted | violates | unknown | none
|
|
11
|
+
underwrit.risk 0–100
|
|
12
|
+
underwrit.act proceed | hold
|
|
13
|
+
underwrit.chain.args_digest the keyed argument digest — never an argument value
|
|
14
|
+
|
|
15
|
+
The same facts are emitted a second time under the names proposed to the OpenTelemetry GenAI
|
|
16
|
+
SIG (`docs/OTEL-PROPOSAL.md`): `gen_ai.policy.decision`, `gen_ai.policy.enforced`,
|
|
17
|
+
`gen_ai.policy.engine`, `gen_ai.policy.version`, `gen_ai.policy.decision.id`, `gen_ai.policy.risk`,
|
|
18
|
+
`gen_ai.policy.provenance`, `gen_ai.session.tainted`, `gen_ai.approval.required`,
|
|
19
|
+
`gen_ai.approval.binding`. Both namespaces stay until the proposal lands or is rejected.
|
|
20
|
+
|
|
21
|
+
`opentelemetry-api` is imported lazily; without it `record` is a no-op. `attributes()` returns the
|
|
22
|
+
dict for callers that want to attach it to their own span.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PROPOSED_ENGINE = "underwrit"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def attributes(decision: dict, *, tool: str, session_id: str) -> dict:
|
|
32
|
+
"""Every attribute `record` sets, as a plain dict (no OpenTelemetry needed)."""
|
|
33
|
+
verdict = decision.get("verdict", "")
|
|
34
|
+
status = (decision.get("authority") or {}).get("status", "none")
|
|
35
|
+
provenance = {"authorized": "user", "untrusted": "untrusted", "unknown": "unknown", "none": "none",
|
|
36
|
+
"violates": "violates"}.get(status, status)
|
|
37
|
+
return {
|
|
38
|
+
"gen_ai.tool.name": tool,
|
|
39
|
+
# Underwrit's own names (kept).
|
|
40
|
+
"underwrit.session.id": session_id,
|
|
41
|
+
"underwrit.decision": verdict,
|
|
42
|
+
"underwrit.decision.id": decision.get("id", ""),
|
|
43
|
+
"underwrit.policy.version": int(decision.get("policyVersion") or 0),
|
|
44
|
+
"underwrit.taint": bool(decision.get("taint")),
|
|
45
|
+
"underwrit.authority": status,
|
|
46
|
+
"underwrit.risk": int(decision.get("risk") or 0),
|
|
47
|
+
"underwrit.act": decision.get("act", ""),
|
|
48
|
+
"underwrit.chain.args_digest": decision.get("argsDigest", ""),
|
|
49
|
+
# The proposed GenAI semantic-convention names (docs/OTEL-PROPOSAL.md).
|
|
50
|
+
"gen_ai.policy.decision": verdict,
|
|
51
|
+
"gen_ai.policy.enforced": bool(decision.get("enforced")),
|
|
52
|
+
"gen_ai.policy.engine": PROPOSED_ENGINE,
|
|
53
|
+
"gen_ai.policy.version": str(decision.get("policyVersion") or 0),
|
|
54
|
+
"gen_ai.policy.decision.id": decision.get("id", ""),
|
|
55
|
+
"gen_ai.policy.risk": int(decision.get("risk") or 0),
|
|
56
|
+
"gen_ai.policy.provenance": provenance,
|
|
57
|
+
"gen_ai.session.tainted": bool(decision.get("taint")),
|
|
58
|
+
"gen_ai.approval.required": verdict == "require_human",
|
|
59
|
+
"gen_ai.approval.binding": decision.get("argsDigest", ""),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def record(decision: dict, *, tool: str, session_id: str, tracer_name: str = "underwrit") -> None:
|
|
65
|
+
try:
|
|
66
|
+
from opentelemetry import trace # type: ignore
|
|
67
|
+
except ImportError:
|
|
68
|
+
return
|
|
69
|
+
tracer = trace.get_tracer(tracer_name)
|
|
70
|
+
with tracer.start_as_current_span("underwrit.decide") as span:
|
|
71
|
+
for k, v in attributes(decision, tool=tool, session_id=session_id).items():
|
|
72
|
+
span.set_attribute(k, v)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Draft a task policy at t=0, with whatever model the runtime already has.
|
|
2
|
+
|
|
3
|
+
from underwrit_client.task import draft_prompt, parse_draft
|
|
4
|
+
policy = parse_draft(llm(draft_prompt(user_request, tools)))
|
|
5
|
+
session = underwrit.open_session(intent=user_request, task_policy=policy)
|
|
6
|
+
|
|
7
|
+
The model sees the user's request and the tool catalogue — never a tool result — and answers
|
|
8
|
+
with the tools the task needs and the values its authority-bearing fields may take. Underwrit enforces
|
|
9
|
+
the answer deterministically at every sink, so nothing an injected string says later can widen it.
|
|
10
|
+
The model is the runtime's; this file only writes the prompt and reads the answer.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
PROMPT = """You are scoping one task for an agent before it runs. You will see only the user's request and the
|
|
19
|
+
tools available. Answer with JSON and nothing else:
|
|
20
|
+
|
|
21
|
+
{"allowedTools": [tool names this request needs],
|
|
22
|
+
"constraints": {"<field>": [exact values or "re:<regex>" the request implies, e.g. a named recipient or amount]},
|
|
23
|
+
"openFields": [fields the request deliberately leaves to content the agent will read, e.g. "recipients"
|
|
24
|
+
when the request says "email each person mentioned in the file"],
|
|
25
|
+
"draftedBy": "<your model name>"}
|
|
26
|
+
|
|
27
|
+
Rules: list only tools the request needs; put a value in constraints only if the request itself names it;
|
|
28
|
+
put a field in openFields only if the request explicitly delegates it to content. Never invent values.
|
|
29
|
+
|
|
30
|
+
Request: {request}
|
|
31
|
+
|
|
32
|
+
Tools: {tools}
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def draft_prompt(request: str, tools: list[str]) -> str:
|
|
37
|
+
return PROMPT.replace("{request}", request.strip()).replace("{tools}", ", ".join(tools))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_draft(text: str) -> dict:
|
|
41
|
+
"""The model's JSON, tolerant of fences. Raises ValueError if it is not a task policy."""
|
|
42
|
+
m = re.search(r"\{.*\}", text, re.S)
|
|
43
|
+
if not m:
|
|
44
|
+
raise ValueError("no JSON object in the draft")
|
|
45
|
+
doc = json.loads(m.group(0))
|
|
46
|
+
out = {"allowedTools": [str(t) for t in doc.get("allowedTools", [])],
|
|
47
|
+
"constraints": {str(k): [str(x) for x in (v if isinstance(v, list) else [v])]
|
|
48
|
+
for k, v in (doc.get("constraints") or {}).items()},
|
|
49
|
+
"openFields": [str(f) for f in doc.get("openFields", [])],
|
|
50
|
+
"draftedBy": str(doc.get("draftedBy") or "")}
|
|
51
|
+
if not out["allowedTools"]:
|
|
52
|
+
raise ValueError("a task policy must name at least one tool")
|
|
53
|
+
return out
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: underwrit-client
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Client for an Underwrit data plane: decide, claim, record, and produce evidence for what an agent did.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/underwrit-io/dataplane
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Topic :: Security
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# underwrit-client
|
|
14
|
+
|
|
15
|
+
Integrate an agent runtime with an Underwrit data plane. Standard library only.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
from underwrit_client import Underwrit, Held
|
|
20
|
+
|
|
21
|
+
underwrit = Underwrit("http://127.0.0.1:8787", token=os.environ["UNDERWRIT_AGENT_TOKEN"])
|
|
22
|
+
|
|
23
|
+
with underwrit.session(agent="ops-assistant", environment="production",
|
|
24
|
+
intent="Restart api during incident 4417") as s:
|
|
25
|
+
logs = s.call("k8s/get_k8s_logs", get_logs, pod="api-1")
|
|
26
|
+
try:
|
|
27
|
+
s.call("k8s/rollout_restart", restart, deployment="api", namespace="prod")
|
|
28
|
+
except Held as h:
|
|
29
|
+
park(h.decision["id"]) # a person answers in the console, then:
|
|
30
|
+
s.resume(h.decision["id"], restart, deployment="api", namespace="prod",
|
|
31
|
+
verify=lambda r: {"status": "passed", "checks": [{"name": "rollout", "ok": True}]})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `call()` decides, runs the function, and records the outcome. It raises `Held` when the data
|
|
35
|
+
plane says `act: "hold"` — enforcement on, verdict held or denied. In shadow mode nothing is
|
|
36
|
+
raised and the verdict is still recorded.
|
|
37
|
+
- `resume()` claims an approval with the same arguments immediately before executing. The claim is
|
|
38
|
+
refused if the arguments or the stated preconditions changed, the approval expired, the policy
|
|
39
|
+
now refuses it, or it was already used.
|
|
40
|
+
- A `TimeoutError` from the wrapped function is recorded as `uncertain`, and the data plane will not
|
|
41
|
+
let a retry overwrite that without `reconcile=True`.
|
|
42
|
+
- `verify=` records what was observed afterwards, separately from whether the call succeeded.
|
|
43
|
+
|
|
44
|
+
The full routes are in the Underwrit README; this client adds nothing the service does not do.
|
|
45
|
+
|
|
46
|
+
## Also
|
|
47
|
+
|
|
48
|
+
- `open_session(..., task_policy={...})` attaches a t=0 task policy; `guarded(session, tool)` wraps a
|
|
49
|
+
function so every call goes through the session.
|
|
50
|
+
- `decide(..., signals={"taint": True, "risk": 80, "source": "my-classifier"})` lets an external
|
|
51
|
+
classifier raise taint or risk; it can never lower them.
|
|
52
|
+
- `receipt(seq)` returns the per-entry receipt (leaf index, tree size, root, inclusion proof, signed
|
|
53
|
+
checkpoint) once a checkpoint covers the entry; `Decision["receipt"]` carries the chain position now.
|
|
54
|
+
- 429 and 503 are retried with `Retry-After` (three times by default, capped at thirty seconds).
|
|
55
|
+
- `Held` exposes `id`, `verdict`, `budgeted`, `duplicate`, `quorum` and `risk`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
underwrit_client/__init__.py
|
|
4
|
+
underwrit_client/task.py
|
|
5
|
+
underwrit_client.egg-info/PKG-INFO
|
|
6
|
+
underwrit_client.egg-info/SOURCES.txt
|
|
7
|
+
underwrit_client.egg-info/dependency_links.txt
|
|
8
|
+
underwrit_client.egg-info/entry_points.txt
|
|
9
|
+
underwrit_client.egg-info/top_level.txt
|
|
10
|
+
underwrit_client/integrations/__init__.py
|
|
11
|
+
underwrit_client/integrations/claude_code.py
|
|
12
|
+
underwrit_client/integrations/google_adk.py
|
|
13
|
+
underwrit_client/integrations/http_interceptor.py
|
|
14
|
+
underwrit_client/integrations/langgraph.py
|
|
15
|
+
underwrit_client/integrations/openai_agents.py
|
|
16
|
+
underwrit_client/integrations/otel.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
underwrit_client
|