underwrit 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
underwrit/api.py ADDED
@@ -0,0 +1,1763 @@
1
+ """The data plane: decide, record, resolve, and produce evidence. Runs on the customer's side.
2
+
3
+ It decides locally and it keeps deciding when the control plane is unreachable, because it sits in
4
+ the critical path of production actions: if this service cannot answer, either their agents stop or
5
+ it fails open, and failing open defeats the product. Policy is fetched and cached; a stale policy is
6
+ a far better outcome than no decision.
7
+
8
+ **Nothing sensitive leaves.** The chain records an argument *digest*; the arguments themselves stay
9
+ in the local store under their own retention, and what is reported upstream is counts and digests.
10
+ That is what makes the deployment model answerable to a regulated buyer, and it is enforced here
11
+ rather than promised in a document — `report()` is built from a fixed set of scalar fields, and
12
+ reporting happens on a background queue so the control plane is never in the decision path even
13
+ when it is slow.
14
+
15
+ Every decision is written to the chain as it is made, including the ones that allowed. A log
16
+ containing only refusals cannot show that a control was working the rest of the time, and a log
17
+ containing only successes cannot show somebody probing for what they are not allowed to do.
18
+
19
+ **An approval is a contract, not a flag.** It is bound to the exact arguments (by digest), to the
20
+ policy it was given under, to whatever state the caller said must still be true, and to a clock.
21
+ `/v1/decisions/:id/claim` is where a runtime, about to execute, presents the same arguments and is
22
+ told whether the approval still holds — and a claim is exactly-once, so a repeated request cannot
23
+ become a repeated action.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import hashlib
30
+ import json
31
+ import os
32
+ import queue
33
+ import threading
34
+ import time
35
+ import urllib.error
36
+ import urllib.parse
37
+ import urllib.request
38
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
39
+ from pathlib import Path
40
+
41
+ from . import (
42
+ auth, bundle, cedar, chain, opa, checkpoint as cp_mod, db, evidence, migrate, obs, oidc,
43
+ policy as policy_mod, ratelimit, replay as replay_mod, retention, store)
44
+ from .subject import Subject
45
+
46
+ DB_PATH = os.environ.get("UNDERWRIT_DB", "underwrit.db")
47
+ CONTROL_URL = os.environ.get("UNDERWRIT_CONTROL_URL", "").rstrip("/")
48
+ # Further witnesses beyond the control plane: "https://witness-a|<node token>,https://witness-b|<token>".
49
+ # Each speaks the same C2SP witness protocol (POST /v1/witness) — another Underwrit control plane, run by
50
+ # a different party, is the intended one. A checkpoint cosigned by N independent witnesses is N
51
+ # statements that this history is the one they saw.
52
+ EXTRA_WITNESSES: list[tuple[str, str]] = [
53
+ (part.split("|", 1)[0].strip().rstrip("/"), (part.split("|", 1) + [""])[1].strip())
54
+ for part in os.environ.get("UNDERWRIT_WITNESSES", "").split(",") if part.strip()
55
+ ]
56
+ NODE_ID = os.environ.get("UNDERWRIT_NODE", "node-1")
57
+ # This data plane's credential for the control plane. Minted there with role=node, which may fetch
58
+ # policy and report — never rewrite the policy the fleet receives. Either the value or a file
59
+ # holding it (a mounted secret), read at use so a file that appears after start is picked up.
60
+ NODE_TOKEN = os.environ.get("UNDERWRIT_NODE_TOKEN", "")
61
+ NODE_TOKEN_FILE = os.environ.get("UNDERWRIT_NODE_TOKEN_FILE", "")
62
+ POLL_SECONDS = float(os.environ.get("UNDERWRIT_POLICY_POLL", "30") or 30)
63
+ SWEEP_SECONDS = float(os.environ.get("UNDERWRIT_RETENTION_SWEEP_SECONDS", "3600") or 0)
64
+ RATE_PER_MINUTE = int(os.environ.get("UNDERWRIT_RATE_LIMIT_PER_MINUTE", "600") or 0)
65
+ HOST = os.environ.get("UNDERWRIT_HOST", "127.0.0.1")
66
+ CHECKPOINT_SECONDS = float(os.environ.get("UNDERWRIT_CHECKPOINT_SECONDS", "60") or 0)
67
+
68
+ _local = threading.local()
69
+ _key: cp_mod.Key | None = None
70
+ _key_lock = threading.Lock()
71
+
72
+
73
+ def log_key() -> cp_mod.Key:
74
+ """The Ed25519 log key, loaded once per process from a file (never from the database)."""
75
+ global _key
76
+ with _key_lock:
77
+ if _key is None:
78
+ _key, _ = cp_mod.load_or_create(cp_mod.default_key_path(DB_PATH), f"underwrit/{NODE_ID}")
79
+ return _key
80
+
81
+
82
+ def witness(cp: dict | None = None, c=None) -> dict:
83
+ """Ask the control plane to cosign the latest checkpoint. Failure is reported, never fatal.
84
+
85
+ Sends the checkpoint, the log's public key, the size the witness last cosigned and the
86
+ consistency proof from it. A 409 carrying the witness's size means our record of what it saw
87
+ is stale; we adopt its size and retry once. A 409 saying the proof does not connect is the one
88
+ that matters: the witness holds a root this log can no longer reach, which is what a rewritten
89
+ history looks like from outside, and it is logged as such.
90
+ """
91
+ targets = ([("control", CONTROL_URL, node_token())] if CONTROL_URL else []) + \
92
+ [(url, url, tok) for url, tok in EXTRA_WITNESSES]
93
+ if not targets:
94
+ return {"result": "no witness configured"}
95
+ c = c if c is not None else conn()
96
+ cp = cp or cp_mod.latest(c) or cp_mod.issue(c, log_key())
97
+ results = {label: _witness_one(c, cp, label, url, tok) for label, url, tok in targets}
98
+ primary = results.get("control") or next(iter(results.values()))
99
+ return {**primary, **({"witnesses": results} if len(results) > 1 else {})}
100
+
101
+
102
+ def _witness_one(c, cp: dict, label: str, url: str, tok: str) -> dict:
103
+ state = cp_mod.witness_state_for(c, label)
104
+ old_size = state["size"] if state else 0
105
+ for _ in range(2):
106
+ try:
107
+ proof = cp_mod.consistency(c, old_size)["proof"] if 0 < old_size < cp["size"] else []
108
+ except ValueError:
109
+ proof = []
110
+ body = {"node": NODE_ID, "checkpoint": cp["note"], "logKey": log_key().public_json()["publicKey"],
111
+ "oldSize": old_size, "consistency": proof}
112
+ try:
113
+ req = urllib.request.Request(
114
+ f"{url}/v1/witness", data=json.dumps(body).encode("utf-8"),
115
+ headers={"Content-Type": "application/json",
116
+ **({"Authorization": f"Bearer {tok}"} if tok else {})}, method="POST")
117
+ with urllib.request.urlopen(req, timeout=8) as r:
118
+ out = json.loads(r.read().decode("utf-8"))
119
+ except urllib.error.HTTPError as exc:
120
+ try:
121
+ payload = json.loads(exc.read().decode("utf-8"))
122
+ except ValueError:
123
+ payload = {}
124
+ if exc.code == 409 and "size" in payload and payload["size"] != old_size and "connect" not in payload.get("error", ""):
125
+ old_size = int(payload["size"])
126
+ continue
127
+ obs.log("witness.refused", witness=label, status=exc.code, error=payload.get("error", ""))
128
+ obs.metrics.inc("underwrit_witness_refusals_total", {"witness": label})
129
+ return {"result": "refused", "status": exc.code, **payload}
130
+ except (urllib.error.URLError, OSError, ValueError) as exc:
131
+ return {"result": f"unreachable: {type(exc).__name__}"}
132
+ origin = (out.get("witness") or {}).get("origin", "") or (state or {}).get("witness", "")
133
+ # The witness is another party. Its answer is checked before it is believed: same body we
134
+ # sent, our own signature line intact, and — when it told us its key — a cosignature that
135
+ # verifies under it. A witness that returns anything else is refused, like a bad proof.
136
+ try:
137
+ ours, theirs = cp_mod.parse_note(cp["note"]), cp_mod.parse_note(str(out.get("cosignedNote") or ""))
138
+ same_body = (ours["origin"], ours["size"], ours["root"]) == (theirs["origin"], theirs["size"], theirs["root"])
139
+ log_line = cp_mod.verify_note(out["cosignedNote"], log_key().public, log_key().origin)
140
+ wpub = base64.b64decode((out.get("witness") or {}).get("publicKey") or "") if out.get("witness") else b""
141
+ cosig_ok = True if not wpub else cp_mod.verify_cosignature(out["cosignedNote"], wpub, origin) is not None
142
+ sizes = int(out.get("size")) == cp["size"] and str(out.get("root")) == cp["root"]
143
+ except (ValueError, KeyError, TypeError, IndexError):
144
+ same_body = log_line = cosig_ok = sizes = False
145
+ if not (same_body and log_line and cosig_ok and sizes):
146
+ obs.log("witness.refused", witness=label, status=0, error="witness answer did not verify")
147
+ obs.metrics.inc("underwrit_witness_refusals_total", {"witness": label})
148
+ return {"result": "refused", "error": "the witness's answer did not verify against the checkpoint sent"}
149
+ cp_mod.save_witness_state(c, url=label, size=out["size"], root=out["root"],
150
+ cosigned_note=out["cosignedNote"], witness=origin)
151
+ st = cp_mod.witness_state(c)
152
+ obs.metrics.set("underwrit_witness_fork", 1.0 if st and st.get("fork") else 0.0)
153
+ if st and st.get("fork"):
154
+ obs.log("witness.fork", size=st["size"], roots=len(st["roots"]))
155
+ obs.log("witness.cosigned", witness=label, size=out["size"])
156
+ return {"result": "ok", "size": out["size"], "unchanged": out.get("unchanged", False), "witness": origin}
157
+ return {"result": "refused", "error": "witness size did not converge"}
158
+
159
+
160
+ def anchor(c, cp: dict) -> list[dict]:
161
+ """External clocks (RFC 3161 TSA, OpenTimestamps) over a checkpoint; logged, never fatal."""
162
+ try:
163
+ got = cp_mod.anchor(c, cp)
164
+ except Exception as exc: # noqa: BLE001
165
+ obs.log("timestamp.error", error=type(exc).__name__)
166
+ return []
167
+ for t in got:
168
+ obs.log("timestamp", kind=t["kind"], size=cp["size"])
169
+ obs.metrics.inc("underwrit_timestamps_total", {"kind": t["kind"]})
170
+ return got
171
+
172
+
173
+ _chain_verdict: dict = {"at": 0.0, "verdict": None}
174
+ _chain_lock = threading.Lock()
175
+
176
+
177
+ def chain_verdict(c, max_age: float = 60.0) -> dict:
178
+ """The whole-chain verdict, recomputed at most every `max_age` seconds.
179
+
180
+ `/v1/health` is a liveness probe; walking a large log on every probe is how a healthy pod gets
181
+ killed for being slow. The checkpointer refreshes this on its own clock as well.
182
+ """
183
+ with _chain_lock:
184
+ if _chain_verdict["verdict"] is not None and time.time() - _chain_verdict["at"] < max_age:
185
+ return _chain_verdict["verdict"]
186
+ verdict = chain.verify_chain(c)
187
+ verdict = {**verdict, "checkedAt": time.time()}
188
+ with _chain_lock:
189
+ _chain_verdict.update(at=time.time(), verdict=verdict)
190
+ obs.metrics.set("underwrit_chain_ok", 1 if verdict.get("ok") else 0)
191
+ return verdict
192
+
193
+
194
+ def _checkpointer() -> None:
195
+ while True:
196
+ time.sleep(CHECKPOINT_SECONDS)
197
+ try:
198
+ with _scoped() as c:
199
+ chain_verdict(c, max_age=CHECKPOINT_SECONDS)
200
+ latest = cp_mod.latest(c)
201
+ if not latest or latest["size"] != len(cp_mod.leaves(c)):
202
+ cp = cp_mod.issue(c, log_key())
203
+ obs.log("checkpoint", size=cp["size"], keyId=cp["keyId"])
204
+ witness(cp, c)
205
+ anchor(c, cp)
206
+ except Exception as exc: # noqa: BLE001
207
+ obs.log("checkpoint.error", error=type(exc).__name__)
208
+
209
+
210
+ _migrated = False
211
+ _migrate_lock = threading.Lock()
212
+
213
+
214
+ def conn():
215
+ """This thread's connection. Migrations run once per process, not once per thread — DDL on
216
+ every request thread's first connection is a lock storm on SQLite and noise on Postgres."""
217
+ global _migrated
218
+ if getattr(_local, "conn", None) is None:
219
+ c = db.connect(DB_PATH)
220
+ if not _migrated:
221
+ with _migrate_lock:
222
+ if not _migrated:
223
+ migrate.data_plane(c)
224
+ _migrated = True
225
+ _local.conn = c
226
+ return _local.conn
227
+
228
+
229
+ class _scoped:
230
+ """A connection for one iteration of a background loop, closed (and on Postgres rolled back and
231
+ returned to the pool) when the iteration ends. A loop that held a pooled connection forever
232
+ starved request threads; one that hit an error mid-transaction stayed aborted for good."""
233
+
234
+ def __enter__(self):
235
+ self.c = db.connect(DB_PATH)
236
+ return self.c
237
+
238
+ def __exit__(self, *exc):
239
+ try:
240
+ if exc[0] is not None:
241
+ try:
242
+ self.c.rollback()
243
+ except Exception: # noqa: BLE001
244
+ pass
245
+ finally:
246
+ try:
247
+ self.c.close()
248
+ except Exception: # noqa: BLE001
249
+ pass
250
+ return False
251
+
252
+
253
+ def node_token() -> str:
254
+ if NODE_TOKEN:
255
+ return NODE_TOKEN
256
+ if NODE_TOKEN_FILE:
257
+ try:
258
+ return Path(NODE_TOKEN_FILE).read_text().strip()
259
+ except OSError:
260
+ return ""
261
+ return ""
262
+
263
+
264
+ # --------------------------------------------------------------------------------------------
265
+ # Policy: fetched, cached, and never a reason to stop deciding
266
+
267
+ _policy_lock = threading.Lock()
268
+ _policy: policy_mod.Policy | None = None
269
+ _policy_at: float = 0.0
270
+ _policy_source = "default"
271
+
272
+
273
+ def current_policy() -> tuple[policy_mod.Policy, str]:
274
+ global _policy, _policy_source
275
+ with _policy_lock:
276
+ if _policy is None:
277
+ doc, _ = store.cached_policy(conn())
278
+ if doc:
279
+ _policy, _policy_source = policy_mod.Policy.from_json(doc), "cache"
280
+ else:
281
+ _policy, _policy_source = policy_mod.Policy(), "default"
282
+ return _policy, _policy_source
283
+
284
+
285
+ def set_local_policy(doc: dict) -> policy_mod.Policy:
286
+ """A policy written here, for a data plane with no control plane. Cached like a fetched one."""
287
+ global _policy, _policy_at, _policy_source
288
+ current, _ = current_policy()
289
+ merged = {**current.to_json(), **doc, "version": int(current.version) + 1}
290
+ pol = policy_mod.Policy.from_json(merged)
291
+ with _policy_lock:
292
+ _policy, _policy_at, _policy_source = pol, time.time(), "local"
293
+ store.save_policy(conn(), pol.to_json())
294
+ return pol
295
+
296
+
297
+ def refresh_policy() -> str:
298
+ """Pull policy from the control plane. Failure is reported, never fatal."""
299
+ global _policy, _policy_at, _policy_source
300
+ if not CONTROL_URL:
301
+ return "no control plane configured"
302
+ tok = node_token()
303
+ try:
304
+ req = urllib.request.Request(
305
+ f"{CONTROL_URL}/v1/policy",
306
+ headers={"Authorization": f"Bearer {tok}"} if tok else {},
307
+ )
308
+ with urllib.request.urlopen(req, timeout=8) as r:
309
+ doc = json.loads(r.read().decode("utf-8"))
310
+ except urllib.error.HTTPError as exc:
311
+ # Refused, not unreachable. They need different responses — a 401 here means this node's
312
+ # token is wrong or revoked, which no amount of retrying fixes, and reporting it as a
313
+ # network problem sends somebody after one that does not exist.
314
+ obs.log("policy.refresh", result="refused", status=exc.code)
315
+ return f"refused: HTTP {exc.code}"
316
+ except (urllib.error.URLError, OSError, ValueError) as exc:
317
+ obs.log("policy.refresh", result="unreachable", error=type(exc).__name__)
318
+ return f"unreachable: {type(exc).__name__}"
319
+ with _policy_lock:
320
+ _policy = policy_mod.Policy.from_json(doc)
321
+ _policy_at = time.time()
322
+ _policy_source = "control-plane"
323
+ with _scoped() as c:
324
+ store.save_policy(c, doc)
325
+ obs.log("policy.refresh", result="ok", version=doc.get("version"))
326
+ return "ok"
327
+
328
+
329
+ # --------------------------------------------------------------------------------------------
330
+ # Reporting: counts and digests upstream, off the decision path
331
+
332
+ _reports: queue.Queue = queue.Queue(maxsize=10000)
333
+ _workers_lock = threading.Lock()
334
+ _workers_started = False
335
+
336
+
337
+ def _post_report(payload: dict) -> None:
338
+ tok = node_token()
339
+ try:
340
+ req = urllib.request.Request(
341
+ f"{CONTROL_URL}/v1/report",
342
+ data=json.dumps({"node": NODE_ID, **payload}).encode("utf-8"),
343
+ headers={"Content-Type": "application/json",
344
+ **({"Authorization": f"Bearer {tok}"} if tok else {})},
345
+ method="POST",
346
+ )
347
+ urllib.request.urlopen(req, timeout=5).close()
348
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError):
349
+ pass # Reporting is telemetry. It must never affect whether an action was decided.
350
+
351
+
352
+ def _report_worker() -> None:
353
+ while True:
354
+ payload = _reports.get()
355
+ try:
356
+ if CONTROL_URL:
357
+ _post_report(payload)
358
+ finally:
359
+ _reports.task_done()
360
+
361
+
362
+ def _ensure_workers() -> None:
363
+ global _workers_started
364
+ with _workers_lock:
365
+ if not _workers_started:
366
+ threading.Thread(target=_report_worker, daemon=True, name="underwrit-report").start()
367
+ _workers_started = True
368
+
369
+
370
+ def report(payload: dict) -> None:
371
+ """Queue counts and digests for the control plane. Never payloads — see the module docstring."""
372
+ if not CONTROL_URL:
373
+ return
374
+ _ensure_workers()
375
+ try:
376
+ _reports.put_nowait(payload)
377
+ except queue.Full:
378
+ obs.metrics.inc("underwrit_reports_dropped_total")
379
+
380
+
381
+ def flush_reports() -> None:
382
+ """Wait for queued reports to be delivered. For tests and orderly shutdown."""
383
+ _reports.join()
384
+
385
+
386
+ APPROVAL_WEBHOOK = os.environ.get("UNDERWRIT_APPROVAL_WEBHOOK", "").strip()
387
+ CONSOLE_URL = os.environ.get("UNDERWRIT_CONSOLE_URL", "").rstrip("/")
388
+ _notify_q: queue.Queue = queue.Queue(maxsize=1000)
389
+ _escalate_q: queue.Queue = queue.Queue(maxsize=1000)
390
+ _notifier_started = False
391
+
392
+
393
+ def notify_hold(rec: dict, sess: dict, decision) -> None:
394
+ """Tell an approval channel that a decision is waiting. Slack-compatible incoming webhook.
395
+
396
+ Carries the tool, session, reasons, risk and a link — never an argument value. The approver
397
+ reads the arguments in the console, where they stay.
398
+ """
399
+ global _notifier_started
400
+ if not APPROVAL_WEBHOOK:
401
+ return
402
+ text = (f"Underwrit: {rec['tool']} held for a person in {sess.get('environment') or 'unknown'} "
403
+ f"(session {sess['id']}, risk {decision.risk}{'' if decision.enforced else ', shadow'}).\n"
404
+ + "\n".join(f"• {r}" for r in decision.reasons[:3])
405
+ + (f"\n{CONSOLE_URL}/#inbox" if CONSOLE_URL else ""))
406
+ payload = {"text": text, "decision": rec["id"], "session": sess["id"], "tool": rec["tool"],
407
+ "environment": sess.get("environment", ""), "risk": decision.risk,
408
+ "enforced": decision.enforced, "reasons": decision.reasons[:3]}
409
+ with _workers_lock:
410
+ if not _notifier_started:
411
+ threading.Thread(target=_notifier, daemon=True, name="underwrit-notify").start()
412
+ threading.Thread(target=_notifier, args=(_escalate_q,), daemon=True, name="underwrit-escalate-notify").start()
413
+ _notifier_started = True
414
+ try:
415
+ _notify_q.put_nowait(payload)
416
+ except queue.Full:
417
+ obs.metrics.inc("underwrit_notifications_dropped_total")
418
+
419
+
420
+ ESCALATION_WEBHOOK = os.environ.get("UNDERWRIT_ESCALATION_WEBHOOK", "").strip() or APPROVAL_WEBHOOK
421
+ ESCALATION_SWEEP_SECONDS = float(os.environ.get("UNDERWRIT_ESCALATION_SWEEP_SECONDS", "30") or 0)
422
+
423
+
424
+ def approver_stats(c, window: float = 86400.0) -> list[dict]:
425
+ """Per approver: answers, allow rate, latency, and a fatigue flag.
426
+
427
+ The flag is a heuristic, named as one: more than twenty answers in the window, over 95% allows,
428
+ and a median latency under ten seconds — the pattern of somebody clicking through rather than
429
+ reading. It is a prompt to look, not a verdict on the person.
430
+ """
431
+ since = time.time() - window
432
+ rows = c.execute(
433
+ "SELECT a.subject, a.verdict, a.at, d.decided_at FROM approvals a JOIN decisions d ON d.id = a.decision_id "
434
+ "ORDER BY a.at ASC").fetchall()
435
+ by: dict[str, dict] = {}
436
+ for r in rows:
437
+ st = by.setdefault(r["subject"], {"approver": r["subject"], "total": 0, "allow": 0, "deny": 0,
438
+ "windowTotal": 0, "windowAllow": 0, "latencies": [], "lastAt": 0.0})
439
+ st["total"] += 1
440
+ st["allow" if r["verdict"] == "allow" else "deny"] += 1
441
+ st["lastAt"] = max(st["lastAt"], float(r["at"]))
442
+ if float(r["at"]) >= since:
443
+ st["windowTotal"] += 1
444
+ st["windowAllow"] += r["verdict"] == "allow"
445
+ st["latencies"].append(max(0.0, float(r["at"]) - float(r["decided_at"] or r["at"])))
446
+ out = []
447
+ for st in by.values():
448
+ lat = sorted(st.pop("latencies"))
449
+ median = lat[len(lat) // 2] if lat else None
450
+ st["medianLatencySeconds"] = round(median, 1) if median is not None else None
451
+ st["meanLatencySeconds"] = round(sum(lat) / len(lat), 1) if lat else None
452
+ st["windowAllowRate"] = round(st["windowAllow"] / st["windowTotal"], 3) if st["windowTotal"] else None
453
+ st["fatigue"] = bool(st["windowTotal"] > 20 and (st["windowAllowRate"] or 0) > 0.95
454
+ and median is not None and median < 10)
455
+ out.append(st)
456
+ return sorted(out, key=lambda x: -x["total"])
457
+
458
+
459
+ def escalate_overdue(c) -> list[str]:
460
+ """Holds past the approval SLA that nobody has been told about a second time: one escalation
461
+ each, chained, to UNDERWRIT_ESCALATION_WEBHOOK (or the approval webhook). Returns the decision ids."""
462
+ pol, _ = current_policy()
463
+ if not pol.approval_sla:
464
+ return []
465
+ now = time.time()
466
+ done: list[str] = []
467
+ for r in store.overdue_unescalated(c, pol.approval_sla):
468
+ waited = int(now - float(r["decidedAt"]))
469
+ payload = {"kind": "escalation", "text": (f"Underwrit: {r['tool']} has waited {waited}s for a person in "
470
+ f"{r.get('environment') or 'unknown'} (SLA {pol.approval_sla}s, "
471
+ f"session {r['sessionId']})" + (f"\n{CONSOLE_URL}/#inbox/{r['id']}" if CONSOLE_URL else "")),
472
+ "decision": r["id"], "session": r["sessionId"], "tool": r["tool"],
473
+ "environment": r.get("environment", ""), "waitedSeconds": waited, "slaSeconds": pol.approval_sla,
474
+ "answeredBy": [a["subject"] for a in r.get("approvals", [])], "needed": r.get("needed")}
475
+ store.mark_escalated(c, r["id"])
476
+ chain.record(c, actor="system", actor_id=r["sessionId"], action="decision.escalate", outcome="noted",
477
+ environment=r.get("environment", ""), target=r["tool"],
478
+ reason=f"held {waited}s, SLA {pol.approval_sla}s",
479
+ detail={"sessionId": r["sessionId"], "decisionId": r["id"], "waitedSeconds": waited,
480
+ "slaSeconds": pol.approval_sla}, source_ip="")
481
+ obs.metrics.inc("underwrit_holds_escalated_total", {"environment": r.get("environment", "")})
482
+ obs.log("hold.escalated", decision=r["id"], waited=waited)
483
+ if ESCALATION_WEBHOOK:
484
+ global _notifier_started
485
+ with _workers_lock:
486
+ if not _notifier_started:
487
+ threading.Thread(target=_notifier, daemon=True, name="underwrit-notify").start()
488
+ threading.Thread(target=_notifier, args=(_escalate_q,), daemon=True, name="underwrit-escalate-notify").start()
489
+ _notifier_started = True
490
+ try:
491
+ # Its own queue and worker: an escalation must not wait behind a backlog of holds.
492
+ _escalate_q.put_nowait({**payload, "_url": ESCALATION_WEBHOOK})
493
+ except queue.Full:
494
+ obs.metrics.inc("underwrit_notifications_dropped_total", {"queue": "escalation"})
495
+ done.append(r["id"])
496
+ return done
497
+
498
+
499
+ SHADOW_HOLD_TTL = float(os.environ.get("UNDERWRIT_SHADOW_HOLD_TTL_SECONDS", str(7 * 86400)) or 0)
500
+
501
+
502
+ def expire_shadow_holds(c) -> list[str]:
503
+ """A hold in an environment that is not enforcing was never going to stop anything; after
504
+ UNDERWRIT_SHADOW_HOLD_TTL_SECONDS it is closed as `expired` so the inbox shows what still matters.
505
+ Chained, like every other way a decision is settled."""
506
+ if SHADOW_HOLD_TTL <= 0:
507
+ return []
508
+ pol, _ = current_policy()
509
+ done: list[str] = []
510
+ for r in store.stale_holds(c, SHADOW_HOLD_TTL):
511
+ env = r.get("environment", "")
512
+ if pol.enforcing(env):
513
+ continue
514
+ settled = store.resolve_decision(c, r["id"], approver="system", verdict="expired",
515
+ reason=f"shadow hold older than {int(SHADOW_HOLD_TTL)}s")
516
+ if not settled or not settled.get("settledNow"):
517
+ continue
518
+ chain.record(c, actor="system", actor_id=r["sessionId"], action="decision.expire", outcome="noted",
519
+ environment=env, target=r["tool"], reason="shadow hold expired unanswered",
520
+ detail={"sessionId": r["sessionId"], "decisionId": r["id"], "ttlSeconds": SHADOW_HOLD_TTL},
521
+ source_ip="")
522
+ obs.metrics.inc("underwrit_holds_expired_total", {"environment": env})
523
+ done.append(r["id"])
524
+ return done
525
+
526
+
527
+ def _escalator() -> None:
528
+ while True:
529
+ time.sleep(ESCALATION_SWEEP_SECONDS)
530
+ try:
531
+ with _scoped() as c:
532
+ escalate_overdue(c)
533
+ expire_shadow_holds(c)
534
+ except Exception as exc: # noqa: BLE001
535
+ obs.log("escalation.error", error=type(exc).__name__)
536
+
537
+
538
+ def _notifier(q: "queue.Queue | None" = None) -> None:
539
+ q = q if q is not None else _notify_q
540
+ while True:
541
+ payload = q.get()
542
+ url = payload.pop("_url", None) or APPROVAL_WEBHOOK
543
+ try:
544
+ req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"),
545
+ headers={"Content-Type": "application/json"}, method="POST")
546
+ urllib.request.urlopen(req, timeout=5).close()
547
+ obs.metrics.inc("underwrit_notifications_total", {"result": "sent"})
548
+ except (urllib.error.HTTPError, urllib.error.URLError, OSError):
549
+ obs.metrics.inc("underwrit_notifications_total", {"result": "failed"})
550
+ finally:
551
+ _notify_q.task_done()
552
+
553
+
554
+ def _poller() -> None:
555
+ while True:
556
+ refresh_policy()
557
+ time.sleep(POLL_SECONDS)
558
+
559
+
560
+ def _sweeper() -> None:
561
+ while True:
562
+ time.sleep(SWEEP_SECONDS)
563
+ try:
564
+ with _scoped() as c:
565
+ r = retention.sweep(c, actor="system:retention", record_empty=False)
566
+ if r["purged"]:
567
+ obs.metrics.inc("underwrit_retention_purged_total", value=r["purged"])
568
+ obs.log("retention.sweep", purged=r["purged"], sessions=r["sessions"])
569
+ except Exception as exc: # noqa: BLE001 — a sweep must never take the service down
570
+ obs.log("retention.error", error=type(exc).__name__)
571
+
572
+
573
+ # --------------------------------------------------------------------------------------------
574
+ # Rate limiting
575
+
576
+ _limiter: ratelimit.Limiter | None = None
577
+
578
+
579
+ def limiter() -> ratelimit.Limiter:
580
+ global _limiter
581
+ if _limiter is None:
582
+ _limiter = ratelimit.Limiter(RATE_PER_MINUTE)
583
+ return _limiter
584
+
585
+
586
+ # --------------------------------------------------------------------------------------------
587
+ # Handlers
588
+
589
+ FAILED_OUTCOMES = ("failed", "error", "denied")
590
+ OUTCOMES = ("succeeded", "failed", "denied", "uncertain", "skipped", "error")
591
+
592
+
593
+ def _act(verdict: str, enforced: bool) -> str:
594
+ # `act` is what the caller should actually do. In shadow mode a held verdict still says
595
+ # "proceed" — the operator has not turned enforcement on, and a client that blocked anyway
596
+ # would be enforcing a policy nobody enabled.
597
+ return "hold" if verdict in (policy_mod.REQUIRE_HUMAN, policy_mod.DENY) and enforced else "proceed"
598
+
599
+
600
+ def h_decide(body: dict) -> tuple[int, dict]:
601
+ session_id = str(body.get("session") or "")
602
+ tool = str(body.get("tool") or (body.get("action") or {}).get("kind") or "")
603
+ if not tool:
604
+ return 400, {"error": "tool is required"}
605
+
606
+ c = conn()
607
+ sess = store.get_session(c, session_id) if session_id else None
608
+ if sess is None:
609
+ sess = store.open_session(
610
+ c, session_id=session_id, actor=str(body.get("actor") or ""),
611
+ agent=str(body.get("agent") or ""), environment=str(body.get("environment") or ""),
612
+ intent=str(body.get("intent") or ""),
613
+ task_policy=body.get("taskPolicy") if isinstance(body.get("taskPolicy"), dict) else None,
614
+ )
615
+
616
+ for field_name in ("arguments", "action", "taskPolicy"):
617
+ if field_name in body and body[field_name] is not None and not isinstance(body[field_name], dict):
618
+ return 400, {"error": f"{field_name} must be a JSON object"}
619
+ if "preconditions" in body and isinstance(body["preconditions"], (str, int, float, bool)):
620
+ return 400, {"error": "preconditions must be a JSON object or array"}
621
+ idem = str(body.get("idempotencyKey") or body.get("idempotency_key") or "")
622
+ if idem:
623
+ existing = store.find_by_idempotency(c, sess["id"], idem)
624
+ if existing:
625
+ # The same request again is the same decision. Recording it twice would make a retry
626
+ # look like two proposals, and a claim on either would let one approval run twice.
627
+ return 200, {**_decision_view(existing), "session": sess["id"], "duplicate": True,
628
+ "act": _act(existing["verdict"], existing["enforced"])}
629
+
630
+ pol, _ = current_policy()
631
+ args = body.get("arguments") or {}
632
+ preconditions = body.get("preconditions")
633
+ # An external classifier's verdict, if the runtime ran one: it can raise taint and risk, never
634
+ # lower them. Underwrit stays a provenance engine; the classifier stays outside it.
635
+ signals = body.get("signals") if isinstance(body.get("signals"), dict) else None
636
+ if signals and signals.get("taint"):
637
+ mark = f"signal:{str(signals.get('source') or 'classifier')[:40]}"
638
+ if mark not in (sess.get("taint") or []):
639
+ store.set_taint(c, sess["id"], [*(sess.get("taint") or []), mark])
640
+ sess = store.get_session(c, sess["id"]) or sess
641
+ # v2: the authority check reads the user's request and what earlier calls returned. Nothing
642
+ # propagates through the model; a value is looked up at the sink in text the session holds.
643
+ prov = policy_mod.Provenance.from_history(
644
+ intent=sess["intent"], history=store.history_for(c, sess["id"]), policy=pol,
645
+ task=policy_mod.TaskPolicy.from_json(sess.get("taskPolicy")))
646
+ decision = policy_mod.decide(
647
+ tool=tool, environment=sess["environment"], taint=sess["taint"], policy=pol,
648
+ arguments=args if isinstance(args, dict) else {}, provenance=prov,
649
+ held_so_far=store.held_count(c, sess["id"]),
650
+ session_totals=store.session_totals(c, sess["id"], pol),
651
+ )
652
+ # An external policy decision point, if one is configured. It may tighten Underwrit's verdict; the
653
+ # exchange is chained so an auditor sees which rule spoke, and a hold that came from OPA says so.
654
+ decision, external = opa.consult(decision, tool=tool, environment=sess["environment"], session_id=sess["id"],
655
+ arguments=args if isinstance(args, dict) else {}, intent=sess["intent"])
656
+ if external:
657
+ obs.metrics.inc("underwrit_opa_total", {"result": external.get("result", ""),
658
+ "applied": str(bool(external.get("applied"))).lower()})
659
+ if signals and isinstance(signals.get("risk"), (int, float)) and int(signals["risk"]) > decision.risk:
660
+ decision = policy_mod.Decision(decision.verdict, list(decision.reasons)
661
+ + [f"risk raised to {int(signals['risk'])} by {str(signals.get('source') or 'classifier')[:40]}"],
662
+ decision.enforced, decision.taint, decision.kinds, decision.authority,
663
+ max(0, min(100, int(signals["risk"]))), decision.budgeted)
664
+ frozen = store.active_freeze(c, session_id=sess["id"], agent=sess["agent"])
665
+ if frozen:
666
+ # The stop button. Every proposal is denied until somebody releases it, and the denial names
667
+ # who pressed it — the record of a stop is as much evidence as the record of an action.
668
+ decision = policy_mod.Decision(
669
+ policy_mod.DENY, [f"{frozen['kind']} frozen by {frozen['frozenBy']}: {frozen['reason'] or 'no reason given'}"],
670
+ decision.enforced, decision.taint, decision.kinds, decision.authority, 100)
671
+ rec = None
672
+ for _attempt in range(3):
673
+ seq = store.next_seq(c, sess["id"])
674
+ try:
675
+ rec = store.record_decision(c, session_id=sess["id"], tool=tool, args=args,
676
+ decision=decision, seq=seq, policy_version=pol.version,
677
+ preconditions=preconditions, idempotency_key=idem)
678
+ break
679
+ except Exception as exc: # noqa: BLE001 — only a unique-index collision is retried
680
+ msg = str(exc).lower()
681
+ if "unique" not in msg and "duplicate" not in msg:
682
+ raise
683
+ try:
684
+ c.rollback()
685
+ except Exception: # noqa: BLE001
686
+ pass
687
+ if idem:
688
+ existing = store.find_by_idempotency(c, sess["id"], idem)
689
+ if existing:
690
+ return 200, {**_decision_view(existing), "session": sess["id"], "duplicate": True,
691
+ "act": _act(existing["verdict"], existing["enforced"])}
692
+ if rec is None:
693
+ return 503, {"error": "could not record the decision after concurrent retries"}
694
+
695
+ entry = chain.record(
696
+ c, actor=f"agent:{sess['agent'] or 'unknown'}", actor_id=sess["id"], action="decision",
697
+ outcome=decision.verdict, environment=sess["environment"], target=tool,
698
+ reason="; ".join(decision.reasons),
699
+ detail={"sessionId": sess["id"], "decisionId": rec["id"], "argsDigest": rec["argsDigest"],
700
+ "preconditionsDigest": rec["preconditionsDigest"], "policyVersion": pol.version,
701
+ "enforced": decision.enforced, "taint": decision.taint,
702
+ # Field names and provenance flags only — never a value. This is the chain.
703
+ "authority": {"status": decision.authority["status"],
704
+ "fields": decision.authority["fields"],
705
+ "detail": decision.authority["detail"]},
706
+ "risk": decision.risk, "budgeted": decision.budgeted,
707
+ **({"external": {"opa": external}} if external else {}),
708
+ **({"signals": {"source": str(signals.get("source") or "classifier")[:40], "taint": bool(signals.get("taint")),
709
+ "risk": signals.get("risk") if isinstance(signals.get("risk"), (int, float)) else None}}
710
+ if signals else {})},
711
+ source_ip="",
712
+ )
713
+ obs.metrics.inc("underwrit_decisions_total", {"verdict": decision.verdict,
714
+ "environment": sess["environment"],
715
+ "enforced": str(decision.enforced).lower()})
716
+ # The endorsement-request rate is the number to watch (arXiv 2606.26479): holds over sink calls.
717
+ if policy_mod.SINK in decision.kinds:
718
+ obs.metrics.inc("underwrit_sink_calls_total", {"environment": sess["environment"]})
719
+ if decision.verdict == policy_mod.REQUIRE_HUMAN:
720
+ obs.metrics.inc("underwrit_endorsement_requests_total", {"environment": sess["environment"]})
721
+ obs.log("decision", session=sess["id"], decision=rec["id"], tool=tool,
722
+ environment=sess["environment"], verdict=decision.verdict,
723
+ enforced=decision.enforced, argsDigest=rec["argsDigest"])
724
+ report({"kind": "decision", "verdict": decision.verdict, "environment": sess["environment"],
725
+ "enforced": decision.enforced, "argsDigest": rec["argsDigest"]})
726
+ if decision.verdict == policy_mod.REQUIRE_HUMAN:
727
+ notify_hold(rec, sess, decision)
728
+
729
+ out = {
730
+ "id": rec["id"], "session": sess["id"], **decision.to_json(),
731
+ "argsDigest": rec["argsDigest"], "preconditionsDigest": rec["preconditionsDigest"],
732
+ "policyVersion": pol.version, "act": _act(decision.verdict, decision.enforced),
733
+ # The receipt: where this decision sits in the chain, now. The Merkle proof against a
734
+ # signed checkpoint follows at GET /v1/receipts/<seq> once a checkpoint covers it.
735
+ "receipt": {"seq": entry.get("seq"), "hash": entry.get("hash"), "prevHash": entry.get("prevHash") or entry.get("prev_hash"),
736
+ "proof": f"/v1/receipts/{entry.get('seq')}"},
737
+ }
738
+ if pol.terse_feedback and decision.verdict in (policy_mod.REQUIRE_HUMAN, policy_mod.DENY):
739
+ # The agent learns that it is held, not why. The why is in the chain and the console; an
740
+ # agent that is told which value tripped the check can try another (arXiv 2604.04035).
741
+ out["reasons"] = ["held for a person"]
742
+ out["authority"] = {"status": "withheld", "fields": []}
743
+ return 200, out
744
+
745
+
746
+ def _decision_view(rec: dict) -> dict:
747
+ return {k: rec[k] for k in ("id", "sessionId", "seq", "tool", "argsDigest", "verdict",
748
+ "enforced", "reasons", "taint", "decidedAt", "resolvedBy",
749
+ "resolvedVerdict", "resolvedAt", "outcome", "policyVersion",
750
+ "preconditionsDigest", "claimedAt", "verification")}
751
+
752
+
753
+ def h_claim(decision_id: str, body: dict) -> tuple[int, dict]:
754
+ """Revalidate an approval immediately before execution, and use it exactly once.
755
+
756
+ Refusals are recorded with the reason, and in shadow mode the caller is still told to proceed —
757
+ what changes is that the chain shows an approval that would not have held.
758
+ """
759
+ c = conn()
760
+ rec = store.get_decision(c, decision_id)
761
+ if not rec:
762
+ return 404, {"error": "no such decision"}
763
+ sess = store.get_session(c, rec["sessionId"]) or {}
764
+ env = sess.get("environment", "")
765
+ pol, _ = current_policy()
766
+ enforced = pol.enforcing(env)
767
+ now = time.time()
768
+
769
+ refusal = ""
770
+ if rec["outcome"] == "uncertain":
771
+ refusal = ("the previous execution of this decision is uncertain — reconcile it with the "
772
+ "target and report an outcome before retrying")
773
+ elif rec["claimedAt"]:
774
+ refusal = "already claimed — an approval is used exactly once"
775
+ elif rec["verdict"] == policy_mod.DENY:
776
+ refusal = "denied by policy"
777
+ elif rec["verdict"] == policy_mod.REQUIRE_HUMAN:
778
+ if rec["resolvedVerdict"] == "deny":
779
+ refusal = "refused by " + (rec["resolvedBy"] or "an approver")
780
+ elif rec["resolvedVerdict"] == "expired":
781
+ refusal = "the hold expired unanswered — propose the action again"
782
+ elif rec["resolvedVerdict"] != "allow":
783
+ refusal = "awaiting approval"
784
+ elif rec["resolvedAt"] and now - rec["resolvedAt"] > pol.approval_ttl:
785
+ refusal = (f"approval expired — granted {int(now - rec['resolvedAt'])}s ago, "
786
+ f"valid for {pol.approval_ttl}s")
787
+ else:
788
+ # The quorum is re-evaluated under the policy in force now, not the one at approval
789
+ # time: a one-person shadow approval must not stay claimable after production is
790
+ # switched to enforcing, where two distinct people are the rule.
791
+ q = auth.quorum(c, decision_id=decision_id, environment=env, enforcing=enforced)
792
+ if not q["met"]:
793
+ refusal = (f"approval no longer satisfies the quorum in force ({q['have']} of "
794
+ f"{q['needed']} distinct people) — propose the action again")
795
+ key = store.args_key_of(c, decision_id)
796
+ if not refusal and ("arguments" in body or "preconditions" in body) and not key:
797
+ refusal = ("the decision's arguments have been purged by retention, so a claim can no "
798
+ "longer be revalidated against them")
799
+ if not refusal and "arguments" in body:
800
+ if store.digest(body.get("arguments") or {}, key) != rec["argsDigest"]:
801
+ refusal = "arguments changed since the decision — reconsideration required"
802
+ if not refusal and "preconditions" in body:
803
+ if store.digest(body.get("preconditions"), key) != rec["preconditionsDigest"]:
804
+ refusal = "relevant state changed since the decision — reconsideration required"
805
+ if not refusal:
806
+ # Recheck under the policy in force now. An approval survives a policy change unless the
807
+ # new policy would have refused the action outright, or would hold one that was allowed
808
+ # without a person. The session's taint is the one recorded at decision time — the
809
+ # question is whether *this* approval still holds, not whether a new one would be needed.
810
+ fresh = policy_mod.decide(tool=rec["tool"], environment=env, taint=rec["taint"], policy=pol)
811
+ if fresh.verdict == policy_mod.DENY:
812
+ refusal = f"policy v{pol.version} now denies {rec['tool']}"
813
+ elif (fresh.verdict == policy_mod.REQUIRE_HUMAN
814
+ and rec["verdict"] in (policy_mod.ALLOW, policy_mod.ALLOW_RECORDED)):
815
+ refusal = f"policy v{pol.version} now requires a person for {rec['tool']}"
816
+
817
+ actor = f"agent:{sess.get('agent') or 'unknown'}"
818
+ if refusal:
819
+ chain.record(c, actor=actor, actor_id=rec["sessionId"], action="decision.claim",
820
+ outcome="denied", environment=env, target=rec["tool"], reason=refusal,
821
+ detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
822
+ "enforced": enforced}, source_ip="")
823
+ obs.metrics.inc("underwrit_claims_total", {"result": "refused", "enforced": str(enforced).lower()})
824
+ return 200, {"ok": False, "act": "hold" if enforced else "proceed", "enforced": enforced,
825
+ "reason": refusal, "decision": _decision_view(rec)}
826
+
827
+ if not store.claim_decision(c, decision_id, at=now):
828
+ # Two claims raced past the read above; the conditional UPDATE is the gate, not the read.
829
+ refusal = "already claimed — an approval is used exactly once"
830
+ chain.record(c, actor=actor, actor_id=rec["sessionId"], action="decision.claim",
831
+ outcome="denied", environment=env, target=rec["tool"], reason=refusal,
832
+ detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
833
+ "enforced": enforced}, source_ip="")
834
+ obs.metrics.inc("underwrit_claims_total", {"result": "refused", "enforced": str(enforced).lower()})
835
+ return 200, {"ok": False, "act": "hold" if enforced else "proceed", "enforced": enforced,
836
+ "reason": refusal, "decision": _decision_view(store.get_decision(c, decision_id))}
837
+ chain.record(c, actor=actor, actor_id=rec["sessionId"], action="decision.claim",
838
+ outcome="allowed", environment=env, target=rec["tool"],
839
+ reason="approval revalidated against arguments, state, policy and time",
840
+ detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
841
+ "argsDigest": rec["argsDigest"], "policyVersion": pol.version,
842
+ "approvedBy": rec["resolvedBy"]}, source_ip="")
843
+ obs.metrics.inc("underwrit_claims_total", {"result": "allowed", "enforced": str(enforced).lower()})
844
+ return 200, {"ok": True, "act": "proceed", "enforced": enforced, "reason": "",
845
+ "decision": _decision_view(store.get_decision(c, decision_id))}
846
+
847
+
848
+ def h_outcome(body: dict) -> tuple[int, dict]:
849
+ did = str(body.get("decision") or body.get("decision_id") or "")
850
+ c = conn()
851
+ rec = store.get_decision(c, did)
852
+ if not rec:
853
+ return 404, {"error": "no such decision"}
854
+ status = str(body.get("status") or "")
855
+ detail = str(body.get("detail") or "")
856
+ if rec["outcome"] == "uncertain" and status and not body.get("reconcile"):
857
+ # An uncertain write is replaced only by a deliberate reconciliation, so a retry cannot
858
+ # quietly overwrite the fact that nobody knew whether the first attempt happened.
859
+ return 409, {"error": "outcome is uncertain; pass reconcile: true with what the target shows"}
860
+ store.set_outcome(c, did, outcome=status, detail=detail)
861
+
862
+ sess = store.get_session(c, rec["sessionId"]) or {}
863
+ new_taint = policy_mod.taint_after(
864
+ tool=rec["tool"], ok=status not in FAILED_OUTCOMES, returned=detail,
865
+ taint=sess.get("taint") or [], policy=current_policy()[0],
866
+ )
867
+ if new_taint != (sess.get("taint") or []):
868
+ store.set_taint(c, rec["sessionId"], new_taint)
869
+
870
+ actor = f"agent:{sess.get('agent') or 'unknown'}"
871
+ # Tool output is customer data with a retention clock; the chain is forever. It gets a digest
872
+ # and a length, never the text — the text stays in `outcome_detail` until the sweep takes it.
873
+ returned = (f"returned {len(detail)} chars, sha256 {hashlib.sha256(detail.encode('utf-8')).hexdigest()[:16]}"
874
+ if detail else "returned nothing")
875
+ entry = chain.record(
876
+ c, actor=actor, actor_id=rec["sessionId"],
877
+ action="outcome", outcome=status or "unknown", environment=sess.get("environment", ""),
878
+ target=rec["tool"], reason=f"{status or 'unknown'}; {returned}",
879
+ detail={"sessionId": rec["sessionId"], "decisionId": did, "returnedChars": len(detail),
880
+ "returnedDigest": hashlib.sha256(detail.encode("utf-8")).hexdigest() if detail else "",
881
+ "reconciled": bool(body.get("reconcile"))}, source_ip="",
882
+ )
883
+ out = {"seq": entry.get("seq"), "hash": entry["hash"], "taint": new_taint,
884
+ "receipt": {"seq": entry.get("seq"), "hash": entry["hash"], "proof": f"/v1/receipts/{entry.get('seq')}"}}
885
+
886
+ # Verification is recorded separately from execution. "The API returned 200" and "the service
887
+ # is healthy again" are different facts, and a record that conflated them would let a change
888
+ # that broke something read as a success because the call that made it succeeded.
889
+ ver = body.get("verification")
890
+ if isinstance(ver, dict) and ver.get("status"):
891
+ vstatus = str(ver.get("status"))
892
+ checks = ver.get("checks") if isinstance(ver.get("checks"), list) else []
893
+ record = {"status": vstatus, "checks": checks[:50], "at": time.time()}
894
+ store.set_verification(c, did, record)
895
+ v = chain.record(
896
+ c, actor=actor, actor_id=rec["sessionId"], action="decision.verify",
897
+ outcome={"passed": "verified", "failed": "verification_failed"}.get(vstatus, "unverified"),
898
+ environment=sess.get("environment", ""), target=rec["tool"],
899
+ reason=str(ver.get("summary") or "")[:400],
900
+ detail={"sessionId": rec["sessionId"], "decisionId": did, "status": vstatus,
901
+ "checks": [{"name": str(ch.get("name", "")), "ok": bool(ch.get("ok"))}
902
+ for ch in checks[:50] if isinstance(ch, dict)]},
903
+ source_ip="",
904
+ )
905
+ out["verification"] = {"seq": v.get("seq"), "hash": v["hash"], "status": vstatus}
906
+ return 200, out
907
+
908
+
909
+ def h_resolve(decision_id: str, body: dict, identity: dict) -> tuple[int, dict]:
910
+ """Resolve a held decision.
911
+
912
+ The approver is `identity["subject"]` — taken from the token that authenticated the request and
913
+ never from the body. A record of who approved something is worthless if the subject of the
914
+ record supplies it, and this endpoint previously believed `{"approver": "priya"}`.
915
+ """
916
+ c = conn()
917
+ approver = identity["subject"]
918
+ verdict = str(body.get("verdict") or "")
919
+ reason = str(body.get("reason") or "")
920
+ if verdict not in ("allow", "deny"):
921
+ return 400, {"error": "verdict must be allow or deny"}
922
+
923
+ rec = store.get_decision(c, decision_id)
924
+ if not rec:
925
+ return 404, {"error": "no such decision"}
926
+ if rec["verdict"] != policy_mod.REQUIRE_HUMAN:
927
+ return 409, {"error": f"decision is {rec['verdict']}, not held for a person"}
928
+ if rec["resolvedAt"]:
929
+ # Settled. A later answer is not recorded as a resolution it did not produce; the record
930
+ # and the behaviour must agree, and the behaviour is the first settlement.
931
+ return 409, {"error": f"already resolved ({rec['resolvedVerdict']}) by {rec['resolvedBy']}",
932
+ "resolvedVerdict": rec["resolvedVerdict"], "resolvedBy": rec["resolvedBy"],
933
+ "resolvedAt": rec["resolvedAt"]}
934
+
935
+ sess = store.get_session(c, rec["sessionId"]) or {}
936
+ env = sess.get("environment", "")
937
+ pol, _ = current_policy()
938
+ enforcing = pol.enforcing(env)
939
+
940
+ # What the approver was shown versus what was proposed (OWASP ASI09). A client that sends back
941
+ # the arguments it displayed gets them checked against the decision's digest; a mismatch means
942
+ # the person is approving something other than what the agent proposed, and that is refused
943
+ # and chained rather than quietly accepted.
944
+ displayed_ok = None
945
+ if "displayed" in body:
946
+ key = store.args_key_of(c, decision_id)
947
+ displayed_ok = bool(key) and store.digest(body.get("displayed") or {}, key) == rec["argsDigest"]
948
+ if not displayed_ok:
949
+ chain.record(c, actor=approver, actor_id=rec["sessionId"], action="decision.approval",
950
+ outcome="denied", environment=env, target=rec["tool"],
951
+ reason="what the approver was shown does not match what was proposed",
952
+ detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
953
+ "approver": approver, "displayedMatches": False}, source_ip="")
954
+ return 409, {"error": "what you were shown does not match what the agent proposed; "
955
+ "reload the decision before answering", "displayedMatches": False}
956
+
957
+ auth.record_approval(c, decision_id=decision_id, subject=approver,
958
+ verdict=verdict, reason=reason)
959
+ # Approver metrics: who answers, how, and how fast. A person who allows everything in seconds
960
+ # is the fatigue signal the SLA sweeper and GET /v1/approvers surface (arXiv 2606.26479).
961
+ latency = max(0.0, time.time() - float(rec["decidedAt"] or time.time()))
962
+ obs.metrics.inc("underwrit_approvals_total", {"approver": approver, "verdict": verdict})
963
+ obs.metrics.inc("underwrit_approval_latency_seconds_sum", {"approver": approver}, value=latency)
964
+ obs.metrics.inc("underwrit_approval_latency_seconds_count", {"approver": approver})
965
+ q = auth.quorum(c, decision_id=decision_id, environment=env, enforcing=enforcing)
966
+
967
+ # Every answer is chained, including the first of two. A quorum that only recorded the vote that
968
+ # completed it would lose the fact that somebody else had already agreed.
969
+ chain.record(
970
+ c, actor=approver, actor_id=rec["sessionId"], action="decision.approval",
971
+ outcome="allowed" if verdict == "allow" else "denied", environment=env,
972
+ target=rec["tool"], reason=reason,
973
+ detail={"sessionId": rec["sessionId"], "decisionId": decision_id, "approver": approver,
974
+ "argsDigest": rec["argsDigest"], "policyVersion": rec["policyVersion"],
975
+ "displayedMatches": displayed_ok,
976
+ "quorumNeeded": q["needed"], "quorumHave": q["have"]},
977
+ source_ip="",
978
+ )
979
+ report({"kind": "resolve", "verdict": verdict, "environment": env})
980
+
981
+ if not q["met"]:
982
+ return 200, {**rec, "quorum": q,
983
+ "note": f"{q['have']} of {q['needed']} approvals — waiting on another person"}
984
+
985
+ settled = store.resolve_decision(c, decision_id, approver=", ".join(q["approvers"]),
986
+ verdict=q["outcome"], reason=reason) or {**rec, "settledNow": False}
987
+ if not settled.pop("settledNow", False):
988
+ # Another answer settled it between our read and our write. The store kept the first;
989
+ # so does the chain, which never records a resolution that did not take effect.
990
+ return 409, {"error": f"already resolved ({settled['resolvedVerdict']}) by {settled['resolvedBy']}",
991
+ "resolvedVerdict": settled["resolvedVerdict"], "resolvedBy": settled["resolvedBy"],
992
+ "resolvedAt": settled["resolvedAt"]}
993
+ chain.record(
994
+ c, actor=settled["resolvedBy"], actor_id=rec["sessionId"], action="decision.resolve",
995
+ outcome="allowed" if settled["resolvedVerdict"] == "allow" else "denied", environment=env,
996
+ target=rec["tool"], reason=reason,
997
+ detail={"sessionId": rec["sessionId"], "decisionId": decision_id,
998
+ "approvers": q["approvers"], "argsDigest": rec["argsDigest"],
999
+ "policyVersion": rec["policyVersion"], "validForSeconds": pol.approval_ttl},
1000
+ source_ip="",
1001
+ )
1002
+ obs.log("resolve", decision=decision_id, verdict=settled["resolvedVerdict"], approvers=len(q["approvers"]))
1003
+ return 200, {**settled, "quorum": q, "validForSeconds": pol.approval_ttl}
1004
+
1005
+
1006
+ def _subject_for(c, session_id: str) -> Subject | None:
1007
+ sess = store.get_session(c, session_id)
1008
+ if not sess:
1009
+ return None
1010
+ counts = store.decision_counts(c, session_id)
1011
+ holds = retention.active_holds(c, session_id)
1012
+ return Subject(
1013
+ id=session_id, kind="session",
1014
+ descriptor={"agent": sess["agent"], "environment": sess["environment"],
1015
+ "intent": sess["intent"], "openedAt": sess["openedAt"],
1016
+ "closedAt": sess["closedAt"]},
1017
+ sections={
1018
+ "decisions": counts, "taint": sess["taint"],
1019
+ # Visible in the pack: a reader must be able to see that this subject's arguments
1020
+ # survived a sweep because somebody said they must, and who.
1021
+ "legalHold": {"active": bool(holds), "holds": holds},
1022
+ "retention": {"days": retention.days_for(sess["environment"]),
1023
+ "note": "Arguments are purged after this many days unless held; "
1024
+ "the chain holds digests and is unaffected."},
1025
+ },
1026
+ )
1027
+
1028
+
1029
+ def witnessed_now(c) -> None:
1030
+ """Issue a checkpoint at the current size and have the witness cosign it, before an export.
1031
+
1032
+ A bundle should carry a cosigned checkpoint whenever the witness is reachable; issuing one at
1033
+ export time and shipping it unwitnessed would make every fresh bundle look unwitnessed for the
1034
+ few seconds until the timer caught up. If the control plane is down the bundle still ships,
1035
+ with the log's own signature only, and says so.
1036
+ """
1037
+ latest = cp_mod.latest(c)
1038
+ if not latest or latest["size"] != len(cp_mod.leaves(c)):
1039
+ latest = cp_mod.issue(c, log_key())
1040
+ state = cp_mod.witness_state(c)
1041
+ if not state or state["size"] != latest["size"] or state["root"] != latest["root"]:
1042
+ witness(latest)
1043
+ anchor(c, latest)
1044
+
1045
+
1046
+ def h_evidence(session_id: str) -> tuple[int, dict]:
1047
+ c = conn()
1048
+ subject = _subject_for(c, session_id)
1049
+ if subject is None:
1050
+ return 404, {"error": "no such session"}
1051
+ witnessed_now(c)
1052
+ pack, _ = bundle.files_for(c, subject, log_key())
1053
+ return 200, pack
1054
+
1055
+
1056
+ def h_replay(body: dict) -> tuple[int, dict]:
1057
+ pol, _ = current_policy()
1058
+ candidate = policy_mod.Policy.from_json({**pol.to_json(), **(body.get("policy") or body)})
1059
+ since = body.get("since")
1060
+ try:
1061
+ limit = max(1, min(int(body.get("limit") or 500), 5000))
1062
+ except (TypeError, ValueError):
1063
+ return 400, {"error": "limit must be an integer"}
1064
+ return 200, replay_mod.replay(conn(), candidate, since=float(since) if since else None,
1065
+ limit=limit, attribute_changes=True, current=pol)
1066
+
1067
+
1068
+ # --------------------------------------------------------------------------------------------
1069
+ # HTTP
1070
+
1071
+ _ID_PARENTS = {"sessions", "decisions", "evidence", "tokens", "holds", "freezes", "tenants"}
1072
+
1073
+
1074
+ _KNOWN_HEADS = {"health", "ready", "auth", "keys", "sessions", "decide", "outcome", "decisions", "evidence",
1075
+ "pending", "approvers", "policy", "retention", "holds", "tokens", "checkpoint", "freezes", "whoami",
1076
+ "migrations", "witness", "tenants", "usage", "fleet", "report", "chain"}
1077
+
1078
+
1079
+ def route_label(path: str) -> str:
1080
+ """`/v1/decisions/d-123/resolve` → `/v1/decisions/:id/resolve`; unknown paths → `/other`."""
1081
+ return obs.route_label(path, _KNOWN_HEADS, _ID_PARENTS)
1082
+
1083
+
1084
+ MAX_BODY = int(os.environ.get("UNDERWRIT_MAX_BODY_BYTES", str(1 << 20)) or (1 << 20))
1085
+
1086
+
1087
+ class _Answered(Exception):
1088
+ """Raised after a response has already been sent, to unwind a handler cleanly."""
1089
+
1090
+
1091
+ def _num(q: dict, name: str, default, *, lo, hi, integer: bool = False):
1092
+ """A bounded number from a query string, the default when absent, None when malformed."""
1093
+ raw = (q.get(name) or [None])[0]
1094
+ if raw is None:
1095
+ return default
1096
+ try:
1097
+ v = int(raw) if integer else float(raw)
1098
+ except (TypeError, ValueError):
1099
+ return None
1100
+ return v if lo <= v <= hi else None
1101
+
1102
+
1103
+ class Handler(BaseHTTPRequestHandler):
1104
+ server_version = "underwrit/0.3"
1105
+ timeout = float(os.environ.get("UNDERWRIT_SOCKET_TIMEOUT", "30") or 30) # an idle client cannot pin a thread
1106
+
1107
+ def log_message(self, fmt, *args): # structured logging replaces the default
1108
+ pass
1109
+
1110
+ def _send(self, code: int, payload, ctype="application/json", headers: dict | None = None):
1111
+ raw = payload if isinstance(payload, bytes) else json.dumps(payload, indent=2).encode()
1112
+ self._status = code
1113
+ self.send_response(code)
1114
+ self.send_header("Content-Type", ctype)
1115
+ self.send_header("Content-Length", str(len(raw)))
1116
+ self.send_header("Access-Control-Allow-Origin", "*")
1117
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
1118
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
1119
+ for k, v in (headers or {}).items():
1120
+ self.send_header(k, v)
1121
+ self.end_headers()
1122
+ if self.command != "HEAD":
1123
+ self.wfile.write(raw)
1124
+
1125
+ def _identity(self):
1126
+ if getattr(self, "_ident", None) is None:
1127
+ self._ident = auth.identify(conn(), self.headers.get("Authorization"), plane="data") or {}
1128
+ return self._ident or None
1129
+
1130
+ def _gate(self, action: str):
1131
+ """Authorise, or send the refusal and return None.
1132
+
1133
+ Refusals are audited. A log of things that worked cannot show somebody probing for what they
1134
+ are not allowed to do, and that is most of what an audit log is read for.
1135
+ """
1136
+ ident = self._identity()
1137
+ try:
1138
+ if ident and ident.get("breakGlass") and action in ("token.mint", "token.revoke"):
1139
+ raise auth.Denied(403, "an emergency credential cannot mint, rotate or revoke tokens; "
1140
+ "issue what you need from the host")
1141
+ return auth.require(ident, action)
1142
+ except auth.Denied as d:
1143
+ chain.record(
1144
+ conn(), actor=(ident or {}).get("subject", "anonymous"),
1145
+ actor_id=(ident or {}).get("tokenId", ""), action=f"refused.{action}",
1146
+ outcome="denied", environment="", target=self.path.split("?")[0],
1147
+ reason=d.reason, detail={"status": d.status}, source_ip=self.client_address[0],
1148
+ )
1149
+ self._send(d.status, {"error": d.reason})
1150
+ return None
1151
+
1152
+ def _limited(self) -> bool:
1153
+ lim = limiter()
1154
+ if not lim.enabled:
1155
+ return False
1156
+ # Underwrit tokens are looked up locally; anything else (an OIDC JWT, garbage) is limited by
1157
+ # address *before* it can make the plane fetch a JWKS or hash a token.
1158
+ header = self.headers.get("Authorization") or ""
1159
+ local = header.lower().startswith("bearer underwrit_")
1160
+ ident = self._identity() if local else None
1161
+ key = ident["tokenId"] if ident else f"ip:{self.client_address[0]}"
1162
+ ok, wait = lim.allow(key)
1163
+ if ok:
1164
+ return False
1165
+ obs.metrics.inc("underwrit_rate_limited_total")
1166
+ self._send(429, {"error": "rate limited", "retryAfterSeconds": round(wait, 2)},
1167
+ headers={"Retry-After": str(max(1, int(wait + 0.999)))})
1168
+ return True
1169
+
1170
+ def _body(self) -> dict:
1171
+ """The JSON object body, or a 400/413 already sent. Nothing is read before the caller has
1172
+ an identity, so an anonymous client cannot make the plane consume a large body."""
1173
+ raw_len = self.headers.get("Content-Length")
1174
+ try:
1175
+ n = int(raw_len or 0)
1176
+ except (TypeError, ValueError):
1177
+ self._send(400, {"error": "Content-Length must be an integer"})
1178
+ raise _Answered()
1179
+ if n < 0:
1180
+ self._send(400, {"error": "Content-Length must not be negative"})
1181
+ raise _Answered()
1182
+ if n > MAX_BODY:
1183
+ self._send(413, {"error": f"body larger than {MAX_BODY} bytes"})
1184
+ raise _Answered()
1185
+ if not n:
1186
+ return {}
1187
+ try:
1188
+ v = json.loads(self.rfile.read(n).decode("utf-8"))
1189
+ except (ValueError, UnicodeDecodeError):
1190
+ self._send(400, {"error": "body must be valid JSON"})
1191
+ raise _Answered()
1192
+ if not isinstance(v, dict):
1193
+ self._send(400, {"error": "body must be a JSON object"})
1194
+ raise _Answered()
1195
+ return v
1196
+
1197
+ def _dispatch(self, fn) -> None:
1198
+ t0 = time.monotonic()
1199
+ self._status = 0
1200
+ self._ident = None
1201
+ path = self.path.split("?")[0]
1202
+ try:
1203
+ fn(path.rstrip("/") or "/")
1204
+ except _Answered:
1205
+ pass
1206
+ except Exception as exc: # noqa: BLE001 — a handler bug must produce a response, not a hang
1207
+ obs.log("http.error", method=self.command, path=path, error=type(exc).__name__)
1208
+ if not self._status:
1209
+ try:
1210
+ self._send(500, {"error": "internal error"})
1211
+ except OSError:
1212
+ pass
1213
+ finally:
1214
+ ident = self._ident or {}
1215
+ if ident.get("breakGlass") and self._status and path not in ("/v1/health", "/v1/ready"):
1216
+ # Emergency access leaves the fullest trail: every request, by name, chained.
1217
+ try:
1218
+ chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"],
1219
+ action=f"break-glass.{self.command.lower()}", outcome=str(self._status),
1220
+ environment="", target=path, reason="emergency credential in use",
1221
+ detail={"status": self._status}, source_ip=self.client_address[0])
1222
+ obs.metrics.inc("underwrit_break_glass_requests_total")
1223
+ report({"kind": "break-glass", "verdict": self.command.lower(), "environment": path})
1224
+ except Exception: # noqa: BLE001
1225
+ pass
1226
+ if db.is_postgres() and getattr(_local, "conn", None) is not None:
1227
+ # Request threads are short-lived: hand the pooled connection back, or the pool
1228
+ # drains after ten requests and every later one waits on a connection that is never
1229
+ # coming back.
1230
+ try:
1231
+ _local.conn.close()
1232
+ finally:
1233
+ _local.conn = None
1234
+ obs.metrics.inc("underwrit_http_requests_total", {"method": self.command,
1235
+ "route": route_label(path),
1236
+ "status": str(self._status)})
1237
+ obs.log("http", method=self.command, path=path, status=self._status,
1238
+ ms=round((time.monotonic() - t0) * 1000, 1),
1239
+ subject=ident.get("subject", ""), role=ident.get("role", ""),
1240
+ ip=self.client_address[0])
1241
+
1242
+ def do_OPTIONS(self):
1243
+ self._send(204, b"")
1244
+
1245
+ def do_GET(self):
1246
+ self._dispatch(self._get)
1247
+
1248
+ def _get(self, path: str):
1249
+ c = conn()
1250
+ # Health is deliberately open: a load balancer has no token, and it reveals no session,
1251
+ # decision or argument — only whether this process is answering and what it is enforcing.
1252
+ if path == "/v1/health":
1253
+ pol, src = current_policy()
1254
+ verdict = chain_verdict(c)
1255
+ return self._send(200, {
1256
+ "status": "ok", "node": NODE_ID, "policyVersion": pol.version,
1257
+ "policySource": src, "controlPlane": CONTROL_URL or None,
1258
+ "enforcing": pol.enforce, "chain": verdict, "logKeyId": log_key().key_id,
1259
+ })
1260
+ if path == "/v1/ready":
1261
+ # Readiness is "the database answers and this node has the policy it was told to
1262
+ # follow", not "the process is alive". A node configured for a control plane that has
1263
+ # never fetched a policy would be enforcing defaults while looking healthy.
1264
+ try:
1265
+ c.execute("SELECT 1").fetchone()
1266
+ except Exception as exc: # noqa: BLE001
1267
+ return self._send(503, {"ready": False, "error": type(exc).__name__})
1268
+ pol, src = current_policy()
1269
+ if CONTROL_URL and src not in ("control-plane", "cache"):
1270
+ return self._send(503, {"ready": False, "error": "policy has never been fetched from the control plane",
1271
+ "controlPlane": CONTROL_URL, "policySource": src})
1272
+ return self._send(200, {"ready": True, "policySource": src})
1273
+ if path == "/v1/auth/config":
1274
+ # Open: what a browser needs to start a sign-in. Public-client details only.
1275
+ return self._send(200, {**oidc.public_config(), "tokens": True,
1276
+ "breakGlassActive": bool(conn().execute(
1277
+ "SELECT COUNT(*) AS n FROM tokens WHERE break_glass = 1 AND revoked_at IS NULL "
1278
+ "AND (expires_at IS NULL OR expires_at > ?)", (time.time(),)).fetchone()["n"])})
1279
+ if path == "/v1/keys":
1280
+ # Open: a public key is public, and an auditor with a bundle needs it to check origin.
1281
+ # Every key that ever signed a checkpoint here is listed, with when, so a bundle
1282
+ # from before a rotation can still be pinned.
1283
+ k = log_key()
1284
+ history = cp_mod.key_history(c)
1285
+ return self._send(200, {"keys": [{**k.public_json(), "current": True}]
1286
+ + [{**h, "current": False} for h in history if h["keyId"] != k.key_id],
1287
+ "note": "Pin the publicKey when running verify.py to check origin; "
1288
+ "UNDERWRIT_PUBLIC_KEY accepts a comma-separated list."})
1289
+ if self._limited():
1290
+ return
1291
+ if path == "/metrics":
1292
+ if not self._gate("metrics"):
1293
+ return
1294
+ return self._send(200, obs.metrics.render().encode("utf-8"),
1295
+ ctype="text/plain; version=0.0.4; charset=utf-8")
1296
+ if path == "/v1/checkpoint":
1297
+ if not self._gate("read"):
1298
+ return
1299
+ # A read never signs: the console polling this must not mint checkpoints. The first
1300
+ # one is issued so a fresh plane has something to show; after that the checkpointer,
1301
+ # an export, or POST /v1/checkpoint moves it.
1302
+ latest = cp_mod.latest(c) or cp_mod.issue(c, log_key())
1303
+ latest = {**latest, "stale": latest["size"] != len(cp_mod.leaves(c)), "logSize": len(cp_mod.leaves(c))}
1304
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
1305
+ out = {**latest, "history": cp_mod.history(c, 20), "witnessed": cp_mod.witness_state(c),
1306
+ "witnesses": [{k: v for k, v in st.items() if k != "cosignedNote"} for st in cp_mod.witness_states(c)],
1307
+ "timestamps": [{k: v for k, v in t.items() if k != "token"}
1308
+ for t in cp_mod.timestamps_for(c, latest["size"], latest["root"])]}
1309
+ if q.get("from"):
1310
+ try:
1311
+ frm = _num(q, "from", 0, lo=1, hi=1 << 62, integer=True)
1312
+ if frm is None:
1313
+ return self._send(400, {"error": "from must be a positive integer"})
1314
+ out["consistency"] = cp_mod.consistency(c, frm)
1315
+ except ValueError as exc:
1316
+ out["consistency"] = {"error": str(exc)}
1317
+ return self._send(200, out)
1318
+ if path == "/v1/whoami":
1319
+ ident = self._gate("whoami")
1320
+ if not ident:
1321
+ return
1322
+ return self._send(200, {**ident, "grants": sorted(auth.GRANTS.get(ident["role"], ())),
1323
+ "breakGlass": bool(ident.get("breakGlass"))})
1324
+ if path == "/v1/sessions":
1325
+ if not self._gate("read"):
1326
+ return
1327
+ return self._send(200, {"sessions": store.list_sessions(c)})
1328
+ if path.startswith("/v1/receipts/"):
1329
+ # A per-entry receipt (the SCITT shape): leaf index, tree size, root, inclusion proof and
1330
+ # the signed (and, when witnessed, cosigned) checkpoint. 202 until a checkpoint covers it.
1331
+ if not self._gate("decision.read"):
1332
+ return
1333
+ try:
1334
+ seq = int(path[len("/v1/receipts/"):])
1335
+ except ValueError:
1336
+ return self._send(400, {"error": "seq must be an integer"})
1337
+ latest = cp_mod.latest(c)
1338
+ snapshot = cp_mod.tree(c)
1339
+ covered = latest and seq in snapshot[0][:latest["size"]]
1340
+ if not covered:
1341
+ return self._send(202, {"seq": seq, "pending": True,
1342
+ "note": "no signed checkpoint covers this entry yet; ask again after the next checkpoint"})
1343
+ view, proofs = cp_mod.inclusion_for(c, [seq], snapshot=(snapshot[0][:latest["size"]], snapshot[1][:latest["size"]]))
1344
+ pr = proofs.get(seq)
1345
+ state = cp_mod.witness_state(c)
1346
+ note = latest["note"]
1347
+ if state and not state.get("fork") and state["size"] == latest["size"] and state["root"] == latest["root"]:
1348
+ note = state["cosignedNote"]
1349
+ return self._send(200, {"seq": seq, "leafIndex": pr["leafIndex"], "treeSize": latest["size"], "root": latest["root"],
1350
+ "proof": pr["proof"], "checkpoint": note, "keyId": latest["keyId"],
1351
+ "witnessed": bool(state and not state.get("fork") and state["size"] == latest["size"]),
1352
+ "verify": "leaf = SHA-256(0x00 ‖ hash); RFC 9162 inclusion against root; note signed by keyId"})
1353
+ if path == "/v1/approvers":
1354
+ if not self._gate("read"):
1355
+ return
1356
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
1357
+ window = _num(q, "windowSeconds", 86400.0, lo=60.0, hi=366 * 86400.0)
1358
+ if window is None:
1359
+ return self._send(400, {"error": "windowSeconds must be a number between 60 and 31622400"})
1360
+ stats = approver_stats(c, window)
1361
+ for st in stats:
1362
+ obs.metrics.set("underwrit_approver_fatigue", 1.0 if st["fatigue"] else 0.0, {"approver": st["approver"]})
1363
+ return self._send(200, {"approvers": stats, "windowSeconds": window,
1364
+ "fatigueRule": ">20 answers in the window, >95% allows, median latency <10s"})
1365
+ if path == "/v1/pending":
1366
+ if not self._gate("read"):
1367
+ return
1368
+ pol, _ = current_policy()
1369
+ now = time.time()
1370
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
1371
+ limit = _num(q, "limit", 100, lo=1, hi=500, integer=True)
1372
+ offset = _num(q, "offset", 0, lo=0, hi=10_000_000, integer=True)
1373
+ if limit is None or offset is None:
1374
+ return self._send(400, {"error": "limit must be 1..500 and offset a non-negative integer"})
1375
+ total = store.pending_count(c)
1376
+ rows = store.pending(c, limit=limit, offset=offset)
1377
+ for r in rows:
1378
+ r["overdue"] = bool(pol.approval_sla and now - r["decidedAt"] > pol.approval_sla)
1379
+ r["needed"] = 2 if auth.needs_two(r.get("environment", ""), pol.enforcing(r.get("environment", ""))) else 1
1380
+ obs.metrics.set("underwrit_holds_pending", total)
1381
+ obs.metrics.set("underwrit_holds_overdue", store.overdue_count(c, pol.approval_sla) if pol.approval_sla else 0)
1382
+ return self._send(200, {"pending": rows, "slaSeconds": pol.approval_sla, "total": total,
1383
+ "limit": limit, "offset": offset})
1384
+ if path == "/v1/policy":
1385
+ if not self._gate("read"):
1386
+ return
1387
+ pol, src = current_policy()
1388
+ return self._send(200, {"source": src, **pol.to_json()})
1389
+ if path == "/v1/retention":
1390
+ if not self._gate("retention.read"):
1391
+ return
1392
+ return self._send(200, retention.describe(c))
1393
+ if path == "/v1/holds":
1394
+ if not self._gate("retention.read"):
1395
+ return
1396
+ return self._send(200, {"holds": retention.all_holds(c)})
1397
+ if path == "/v1/freezes":
1398
+ if not self._gate("read"):
1399
+ return
1400
+ return self._send(200, {"freezes": store.list_freezes(c, active_only=not self.path.endswith("?all=1"))})
1401
+ if path == "/v1/tokens":
1402
+ if not self._gate("token.read"):
1403
+ return
1404
+ return self._send(200, {"tokens": auth.list_tokens(
1405
+ c, include_revoked=bool(self.path.endswith("?all=1")))})
1406
+ if path == "/v1/migrations":
1407
+ if not self._gate("read"):
1408
+ return
1409
+ return self._send(200, {"component": "data-plane",
1410
+ "version": migrate.current(c, "data-plane"),
1411
+ "history": migrate.history(c, "data-plane")})
1412
+ if path.startswith("/v1/decisions/"):
1413
+ if not self._gate("decision.read"):
1414
+ return
1415
+ did = path[len("/v1/decisions/"):]
1416
+ rec = store.get_decision(c, did)
1417
+ if not rec:
1418
+ return self._send(404, {"error": "no such decision"})
1419
+ if (self._identity() or {}).get("role") == "agent":
1420
+ # An agent sees the contract view: no argument values (it sent them), and, under
1421
+ # terse feedback, no reasons — the same shape /v1/decide gave it.
1422
+ view = _decision_view(rec)
1423
+ pol, _ = current_policy()
1424
+ if pol.terse_feedback and rec["verdict"] in (policy_mod.REQUIRE_HUMAN, policy_mod.DENY):
1425
+ view["reasons"] = ["held for a person"]
1426
+ return self._send(200, {**view, "approvals": [{"verdict": a["verdict"], "at": a["at"]}
1427
+ for a in auth.approvals_for(c, did)]})
1428
+ return self._send(200, {**rec, "approvals": auth.approvals_for(c, did)})
1429
+ if path.startswith("/v1/sessions/"):
1430
+ if not self._gate("read"):
1431
+ return
1432
+ rest = path[len("/v1/sessions/"):]
1433
+ sid, _, tail = rest.partition("/")
1434
+ if tail == "decisions":
1435
+ return self._send(200, {"decisions": store.decisions_for(c, sid)})
1436
+ sess = store.get_session(c, sid)
1437
+ return self._send(200, sess) if sess else self._send(404, {"error": "no such session"})
1438
+ if path.startswith("/v1/evidence/"):
1439
+ if not self._gate("evidence.read"):
1440
+ return
1441
+ rest = path[len("/v1/evidence/"):]
1442
+ sid, _, tail = rest.partition("/")
1443
+ if tail == "bundle":
1444
+ subject = _subject_for(c, sid)
1445
+ if subject is None:
1446
+ return self._send(404, {"error": "no such session"})
1447
+ witnessed_now(c)
1448
+ blob, name = bundle.zip_for(c, subject, log_key())
1449
+ self._status = 200
1450
+ self.send_response(200)
1451
+ self.send_header("Content-Type", "application/zip")
1452
+ self.send_header("Content-Disposition", f'attachment; filename="{name}"')
1453
+ self.send_header("Content-Length", str(len(blob)))
1454
+ self.send_header("Access-Control-Allow-Origin", "*")
1455
+ self.end_headers()
1456
+ return self.wfile.write(blob)
1457
+ code, payload = h_evidence(sid)
1458
+ return self._send(code, payload)
1459
+ self._send(404, {"error": "not found"})
1460
+
1461
+ def do_POST(self):
1462
+ self._dispatch(self._post)
1463
+
1464
+ def _post(self, path: str):
1465
+ if self._limited():
1466
+ return
1467
+ if self._identity() is None:
1468
+ # Every POST on this plane needs a token. Refuse before reading a byte of the body.
1469
+ self._gate("decide")
1470
+ return
1471
+ body = self._body()
1472
+ if path == "/v1/decide":
1473
+ if not self._gate("decide"):
1474
+ return
1475
+ return self._send(*h_decide(body))
1476
+ if path == "/v1/outcome":
1477
+ if not self._gate("outcome"):
1478
+ return
1479
+ return self._send(*h_outcome(body))
1480
+ if path == "/v1/sessions":
1481
+ if not self._gate("session.open"):
1482
+ return
1483
+ c = conn()
1484
+ tp = body.get("taskPolicy") if isinstance(body.get("taskPolicy"), dict) else None
1485
+ s = store.open_session(
1486
+ c, session_id=str(body.get("session") or ""), actor=str(body.get("actor") or ""),
1487
+ agent=str(body.get("agent") or ""),
1488
+ environment=str(body.get("environment") or ""),
1489
+ intent=str(body.get("intent") or ""), task_policy=tp)
1490
+ # The task policy is chained with the session: an auditor sees the scope the task was
1491
+ # given before it read anything, and can judge each later hold against it.
1492
+ ident = self._identity() or {}
1493
+ chain.record(c, actor=f"agent:{s['agent'] or 'unknown'}", actor_id=s["id"],
1494
+ action="session.open", outcome="allowed", environment=s["environment"],
1495
+ target=s["id"], reason="",
1496
+ detail={"sessionId": s["id"], **({"taskPolicy": tp} if tp else {}),
1497
+ # Who opened it, and — from an `act` chain — on whose behalf.
1498
+ "openedBy": ident.get("subject", ""),
1499
+ **({"onBehalfOf": ident["actor"]} if ident.get("actor") else {})},
1500
+ source_ip="")
1501
+ return self._send(200, s)
1502
+ if path.startswith("/v1/sessions/") and path.endswith("/close"):
1503
+ if not self._gate("session.close"):
1504
+ return
1505
+ sid = path[len("/v1/sessions/"):-len("/close")]
1506
+ c = conn()
1507
+ s = store.get_session(c, sid)
1508
+ if not s:
1509
+ return self._send(404, {"error": "no such session"})
1510
+ store.close_session(c, sid)
1511
+ chain.record(c, actor=f"agent:{s['agent'] or 'unknown'}", actor_id=sid,
1512
+ action="session.close", outcome=str(body.get("status") or "succeeded"),
1513
+ environment=s["environment"], target=sid, reason="",
1514
+ detail={"sessionId": sid}, source_ip="")
1515
+ return self._send(200, store.get_session(c, sid))
1516
+ if path.startswith("/v1/decisions/") and path.endswith("/resolve"):
1517
+ ident = self._gate("decision.resolve")
1518
+ if not ident:
1519
+ return
1520
+ did = path[len("/v1/decisions/"):-len("/resolve")]
1521
+ return self._send(*h_resolve(did, body, ident))
1522
+ if path.startswith("/v1/decisions/") and path.endswith("/claim"):
1523
+ if not self._gate("decide"):
1524
+ return
1525
+ did = path[len("/v1/decisions/"):-len("/claim")]
1526
+ return self._send(*h_claim(did, body))
1527
+ if path == "/v1/policy/refresh":
1528
+ if not self._gate("policy.write"):
1529
+ return
1530
+ return self._send(200, {"result": refresh_policy()})
1531
+ if path == "/v1/policy/replay":
1532
+ if not self._gate("policy.read"):
1533
+ return
1534
+ return self._send(*h_replay(body))
1535
+ if path == "/v1/policy":
1536
+ ident = self._gate("policy.write")
1537
+ if not ident:
1538
+ return
1539
+ if CONTROL_URL:
1540
+ # Policy is operator data distributed from one place. A local write that the next
1541
+ # poll silently replaced would be the "looks configured, does nothing" failure.
1542
+ return self._send(409, {"error": "policy is distributed by the control plane; "
1543
+ f"write it there ({CONTROL_URL}/v1/policy)"})
1544
+ pol = set_local_policy(body)
1545
+ chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"],
1546
+ action="policy.write", outcome="allowed", environment="",
1547
+ target=f"v{pol.version}", reason="local policy",
1548
+ detail={"version": pol.version, "enforce": pol.enforce}, source_ip="")
1549
+ return self._send(200, {"source": "local", **pol.to_json()})
1550
+ if path == "/v1/policy/import":
1551
+ ident = self._gate("policy.write")
1552
+ if not ident:
1553
+ return
1554
+ fmt = str(body.get("format") or "cedar")
1555
+ if fmt != "cedar":
1556
+ return self._send(400, {"error": f"unsupported format {fmt!r}; cedar is the one importer"})
1557
+ try:
1558
+ fragment = cedar.to_policy(str(body.get("text") or ""))
1559
+ except cedar.CedarError as exc:
1560
+ return self._send(400, {"error": str(exc), "format": "cedar"})
1561
+ pol, _ = current_policy()
1562
+ merged = cedar.merge(pol.to_json(), fragment)
1563
+ if not body.get("apply"):
1564
+ return self._send(200, {"applied": False, "fragment": fragment, "wouldBecome": merged})
1565
+ new = set_local_policy({k: merged[k] for k in ("deniedTools", "allowlist", "ceilings")})
1566
+ chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"], action="policy.write",
1567
+ outcome="allowed", environment="", target=f"v{new.version}", reason="cedar import",
1568
+ detail={"version": new.version, "format": "cedar",
1569
+ "deniedTools": fragment["deniedTools"], "allowlistFields": sorted(fragment["allowlist"]),
1570
+ "ceilings": fragment["ceilings"]}, source_ip="")
1571
+ return self._send(200, {"applied": True, "fragment": fragment, **new.to_json()})
1572
+ if path == "/v1/holds/escalate":
1573
+ if not self._gate("policy.write"):
1574
+ return
1575
+ return self._send(200, {"escalated": escalate_overdue(conn()), "expired": expire_shadow_holds(conn())})
1576
+ if path == "/v1/checkpoint":
1577
+ if not self._gate("policy.write"):
1578
+ return
1579
+ cp = cp_mod.issue(conn(), log_key())
1580
+ w = witness(cp)
1581
+ return self._send(200, {**cp, "witness": w, "timestamps": anchor(conn(), cp)})
1582
+ if path == "/v1/retention/sweep":
1583
+ ident = self._gate("retention.write")
1584
+ if not ident:
1585
+ return
1586
+ r = retention.sweep(conn(), actor=ident["subject"], dry_run=bool(body.get("dryRun")))
1587
+ if r["purged"]:
1588
+ obs.metrics.inc("underwrit_retention_purged_total", value=r["purged"])
1589
+ return self._send(200, r)
1590
+ if path == "/v1/freezes":
1591
+ ident = self._gate("decision.resolve")
1592
+ if not ident:
1593
+ return
1594
+ try:
1595
+ f = store.freeze(conn(), kind=str(body.get("kind") or "session"), key=str(body.get("key") or ""),
1596
+ by=ident["subject"], reason=str(body.get("reason") or ""))
1597
+ except ValueError as exc:
1598
+ return self._send(400, {"error": str(exc)})
1599
+ chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"], action="freeze",
1600
+ outcome="succeeded", environment="", target=f["key"], reason=f["reason"],
1601
+ detail={"kind": f["kind"], "freezeId": f["id"],
1602
+ **({"sessionId": f["key"]} if f["kind"] == "session" else {})}, source_ip="")
1603
+ return self._send(200, f)
1604
+ if path.startswith("/v1/freezes/") and path.endswith("/release"):
1605
+ ident = self._gate("decision.resolve")
1606
+ if not ident:
1607
+ return
1608
+ fid = path[len("/v1/freezes/"):-len("/release")]
1609
+ f = store.release_freeze(conn(), fid, by=ident["subject"], reason=str(body.get("reason") or ""))
1610
+ if not f:
1611
+ return self._send(404, {"error": "no such freeze"})
1612
+ chain.record(conn(), actor=ident["subject"], actor_id=ident["tokenId"], action="freeze.release",
1613
+ outcome="succeeded", environment="", target=f["key"], reason=f["releaseReason"],
1614
+ detail={"kind": f["kind"], "freezeId": f["id"],
1615
+ **({"sessionId": f["key"]} if f["kind"] == "session" else {})}, source_ip="")
1616
+ return self._send(200, f)
1617
+ if path == "/v1/holds":
1618
+ ident = self._gate("hold.write")
1619
+ if not ident:
1620
+ return
1621
+ try:
1622
+ h = retention.place_hold(conn(), subject=str(body.get("subject") or ""),
1623
+ by=ident["subject"], reason=str(body.get("reason") or ""))
1624
+ except ValueError as exc:
1625
+ return self._send(400, {"error": str(exc)})
1626
+ return self._send(200, h)
1627
+ if path.startswith("/v1/holds/") and path.endswith("/release"):
1628
+ ident = self._gate("hold.write")
1629
+ if not ident:
1630
+ return
1631
+ hid = path[len("/v1/holds/"):-len("/release")]
1632
+ try:
1633
+ h = retention.release_hold(conn(), hid, by=ident["subject"],
1634
+ reason=str(body.get("reason") or ""))
1635
+ except ValueError as exc:
1636
+ return self._send(400, {"error": str(exc)})
1637
+ return self._send(200, h) if h else self._send(404, {"error": "no such hold"})
1638
+ if path == "/v1/tokens":
1639
+ ident = self._gate("token.mint")
1640
+ if not ident:
1641
+ return
1642
+ ttl = body.get("ttlSeconds")
1643
+ try:
1644
+ token, meta = auth.mint(conn(), subject=str(body.get("subject") or ""),
1645
+ role=str(body.get("role") or ""),
1646
+ ttl=float(ttl) if ttl is not None else None)
1647
+ except (ValueError, TypeError) as exc:
1648
+ return self._send(400, {"error": str(exc)})
1649
+ chain.record(conn(), actor=ident["subject"], actor_id=meta["id"], action="token.mint",
1650
+ outcome="allowed", environment="", target=meta["subject"],
1651
+ reason=f"role {meta['role']}", detail=meta, source_ip="")
1652
+ # Returned once. There is no endpoint that reads it back — only the digest is stored.
1653
+ return self._send(200, {**meta, "token": token,
1654
+ "note": "Shown once. It is stored only as a SHA-256 digest."})
1655
+ if path.startswith("/v1/tokens/") and path.endswith("/revoke"):
1656
+ ident = self._gate("token.revoke")
1657
+ if not ident:
1658
+ return
1659
+ tid = path[len("/v1/tokens/"):-len("/revoke")]
1660
+ ok = auth.revoke(conn(), tid)
1661
+ chain.record(conn(), actor=ident["subject"], actor_id=tid, action="token.revoke",
1662
+ outcome="succeeded" if ok else "failed", environment="", target=tid,
1663
+ reason="" if ok else "no such active token", detail={}, source_ip="")
1664
+ return self._send(200 if ok else 404, {"revoked": ok, "id": tid})
1665
+ if path.startswith("/v1/tokens/") and path.endswith("/rotate"):
1666
+ ident = self._gate("token.mint")
1667
+ if not ident:
1668
+ return
1669
+ tid = path[len("/v1/tokens/"):-len("/rotate")]
1670
+ ttl = body.get("ttlSeconds")
1671
+ try:
1672
+ r = auth.rotate(conn(), tid, ttl=float(ttl) if ttl is not None else None)
1673
+ except (ValueError, TypeError) as exc:
1674
+ return self._send(400, {"error": str(exc)})
1675
+ if not r:
1676
+ return self._send(404, {"error": "no such active token"})
1677
+ token, meta = r
1678
+ chain.record(conn(), actor=ident["subject"], actor_id=meta["id"], action="token.rotate",
1679
+ outcome="succeeded", environment="", target=meta["subject"],
1680
+ reason=f"replaces {tid}", detail=meta, source_ip="")
1681
+ return self._send(200, {**meta, "token": token,
1682
+ "note": "Shown once. The previous token is revoked."})
1683
+ self._send(404, {"error": "not found"})
1684
+
1685
+
1686
+ def bootstrap() -> dict:
1687
+ """Mint a first admin token when the database has none, and print it once.
1688
+
1689
+ Printed rather than written to a file or defaulted to a fixed value. A product whose first
1690
+ credential is a known string is a product with no first credential, and one that writes it to
1691
+ disk leaves it there for whoever copies the directory next. If the operator loses this, the
1692
+ remedy is to stop the service and delete the tokens row — not a recovery path that would itself
1693
+ be a way in. (`UNDERWRIT_ADMIN_TOKEN_FILE` is the one exception, for a compose or Kubernetes
1694
+ bootstrap where the file is a mounted secret rather than a directory somebody copies.)
1695
+ """
1696
+ c = conn()
1697
+ n = c.execute("SELECT COUNT(*) AS n FROM tokens WHERE revoked_at IS NULL").fetchone()["n"]
1698
+ if n:
1699
+ return {"bootstrapped": False, "tokens": n}
1700
+ token, meta = auth.mint(c, subject=os.environ.get("UNDERWRIT_ADMIN", "admin"), role="admin", ttl=0)
1701
+ chain.record(c, actor="system", actor_id=meta["id"], action="token.bootstrap",
1702
+ outcome="allowed", environment="", target=meta["subject"],
1703
+ reason="first admin token", detail=meta, source_ip="")
1704
+ return {"bootstrapped": True, "token": token, **meta}
1705
+
1706
+
1707
+ def write_secret_file(path: str, value: str) -> None:
1708
+ p = Path(path)
1709
+ p.parent.mkdir(parents=True, exist_ok=True)
1710
+ p.write_text(value + "\n")
1711
+ try:
1712
+ os.chmod(p, 0o600)
1713
+ except OSError:
1714
+ pass
1715
+
1716
+
1717
+ def serve(host: str | None = None, port: int = 8787) -> None:
1718
+ host = host or HOST
1719
+ Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
1720
+ conn()
1721
+ boot = bootstrap()
1722
+ if CONTROL_URL:
1723
+ refresh_policy()
1724
+ threading.Thread(target=_poller, daemon=True, name="underwrit-policy").start()
1725
+ _ensure_workers()
1726
+ if SWEEP_SECONDS > 0:
1727
+ threading.Thread(target=_sweeper, daemon=True, name="underwrit-retention").start()
1728
+ key = log_key()
1729
+ if CHECKPOINT_SECONDS > 0:
1730
+ threading.Thread(target=_checkpointer, daemon=True, name="underwrit-checkpoint").start()
1731
+ if ESCALATION_SWEEP_SECONDS > 0:
1732
+ threading.Thread(target=_escalator, daemon=True, name="underwrit-escalation").start()
1733
+ pol, src = current_policy()
1734
+ enforcing = [k for k, v in pol.enforce.items() if v] or ["nothing — shadow mode"]
1735
+ say = lambda m: print(m, flush=True)
1736
+ say(f"underwrit data plane http://{host}:{port}")
1737
+ say(f" database {DB_PATH} (schema v{migrate.current(conn(), 'data-plane')})")
1738
+ say(f" control {CONTROL_URL or 'none (local policy)'} [{src}]")
1739
+ say(f" enforcing {', '.join(enforcing)}")
1740
+ say(f" retention {retention.config()['defaultDays']} days"
1741
+ + (f", per environment {retention.config()['environments']}" if retention.config()['environments'] else "")
1742
+ + (f", sweep every {int(SWEEP_SECONDS)}s" if SWEEP_SECONDS > 0 else ", no scheduled sweep"))
1743
+ say(f" rate limit {RATE_PER_MINUTE or 'off'}" + (" requests/min per token" if RATE_PER_MINUTE else ""))
1744
+ say(f" log key {key.key_id} ({cp_mod.default_key_path(DB_PATH)})"
1745
+ + ("" if os.environ.get("UNDERWRIT_SIGNING_KEY_FILE") else
1746
+ " — DEFAULT PATH beside the database: set UNDERWRIT_SIGNING_KEY_FILE outside development, or "
1747
+ "moving the database mints a new key and the witness will refuse it")
1748
+ + (f", checkpoint every {int(CHECKPOINT_SECONDS)}s" if CHECKPOINT_SECONDS > 0 else ""))
1749
+ if boot.get("bootstrapped"):
1750
+ admin_file = os.environ.get("UNDERWRIT_ADMIN_TOKEN_FILE", "")
1751
+ if admin_file:
1752
+ write_secret_file(admin_file, boot["token"])
1753
+ say(f" ADMIN TOKEN written once to {admin_file} (mode 0600); stored only as a digest")
1754
+ else:
1755
+ print()
1756
+ say(" ADMIN TOKEN — shown once, stored only as a digest:")
1757
+ say(f" {boot['token']}")
1758
+ say(f" subject {boot['subject']} · mint others with POST /v1/tokens")
1759
+ else:
1760
+ say(f" tokens {boot['tokens']} active")
1761
+ obs.log("start", plane="data", host=host, port=port, node=NODE_ID,
1762
+ control=bool(CONTROL_URL), policySource=src)
1763
+ ThreadingHTTPServer((host, port), Handler).serve_forever()