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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ [console_scripts]
2
+ underwrit-hook = underwrit_client.integrations.claude_code:main
3
+ underwrit-interceptor = underwrit_client.integrations.http_interceptor:main
@@ -0,0 +1 @@
1
+ underwrit_client