Metadata-Version: 2.4
Name: underwrit
Version: 0.3.0
Summary: The Underwrit data plane: a decision plane for agent tool calls, and evidence anyone can verify offline. Apache-2.0.
License: Apache-2.0
Project-URL: Homepage, https://github.com/underwrit-io/dataplane
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Topic :: Security
Classifier: Topic :: System :: Logging
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Provides-Extra: postgres
Requires-Dist: psycopg[binary,pool]>=3.1; extra == "postgres"
Dynamic: license-file

# Underwrit

Verifiable evidence for what an agent did, and a decision plane in front of it.

Two processes, and the split is the product:

| | |
|---|---|
| **data plane** — `python3 -m underwrit serve` | Runs on your side. Decides, records every decision to a hash chain, produces evidence packs. Keeps deciding when the control plane is unreachable. |
| **control plane** — `python3 -m underwrit control` | Hosted, multi-tenant. Distributes policy, counts usage. Never receives an action's arguments. Never in the decision path. |

```
python3 -m underwrit demo-stack        # both planes, the console, seeded sessions, sign-in links
python3 -m underwrit demo --live       # in a second terminal: an incident held, approved by two people, claimed, verified
```

Or with Docker: `docker compose up -d`, then `docker compose --profile demo run --rm demo`.
[`docs/DEMO.md`](docs/DEMO.md) is the twelve-minute runbook.

Or without Docker: `python3 -m underwrit control 8788`, mint a node token, then
`UNDERWRIT_CONTROL_URL=http://127.0.0.1:8788 UNDERWRIT_NODE_TOKEN=... python3 -m underwrit serve 8787`,
`UNDERWRIT_TOKEN=... python3 -m underwrit demo`, and `cd console && npm install && npm run dev`.
[`docs/OPERATIONS.md`](docs/OPERATIONS.md) has the rest.

## Open core

This repository is the **Underwrit data plane**, licensed under Apache-2.0: the decision engine, the
hash chain, the Merkle checkpoints, the evidence format and the verifier every bundle ships, the
Python and TypeScript clients, the MCP server, the benchmark and the research notes. It runs on
its own with a local policy and produces evidence a third party can verify without trusting you —
which is the part that has to be open for the evidence to mean anything.

**Underwrit Control Plane** is licensed commercially and adds the control plane (policy distribution,
multi-tenancy, witnessing and cosignatures), the approvals console, single sign-on operations,
Helm charts, Terraform and managed deployment. It installs beside this package
(licensed commercially, shipped as container images, never on PyPI or npm) and `underwrit control` / `underwrit demo-stack` hand off to it.

## What it does

An agent proposes an action. The plane answers with the action, everything already in that session,
and the operator's policy — `allow`, `allow_recorded`, `require_human` or `deny` — writes the
decision to a hash chain, and hands back a bundle anybody can verify offline.

Two signals decide. **Taint**: has this session consumed content a third party could have
authored? **Authority**: are the values that carry the action — recipient, amount, host, grant
target, any embedded link — named by the user, by a trusted source, or only by that content? A
tainted hold whose every such value the user named becomes a record; a value that arrived only in
untrusted content is held even for a small action.

An approval is a contract, not a flag: bound to the exact arguments, the policy version, whatever
state the caller said must still hold, and a clock. Immediately before executing, the runtime
*claims* it, and the claim is refused if anything changed, the approval expired, or it was already
used. Verification of what happened afterwards is recorded apart from whether the call succeeded.

```
POST /v1/sessions                   open a session
POST /v1/decide                     propose an action (idempotencyKey, preconditions optional)
POST /v1/decisions/:id/resolve      a person answers a held decision
POST /v1/decisions/:id/claim        revalidate an approval with the same arguments, exactly once
POST /v1/outcome                    what happened, what came back, and what was verified
GET  /v1/evidence/:id               the pack
GET  /v1/evidence/:id/bundle        zip, with verify.py inside
POST /v1/policy/replay              the shadow report: what a candidate policy would have done
GET  /v1/retention  POST /v1/holds  what is kept, and legal holds
GET  /v1/tokens     POST /v1/tokens/:id/rotate|revoke
POST /v1/policy/import              Cedar → deniedTools, allowlist, ceilings (preview, then apply)
GET  /v1/approvers  POST /v1/holds/escalate   who answers, how fast; escalate overdue holds
GET  /v1/checkpoint                 the signed tree, its witnesses and external timestamps
```

Clients: [`sdk/python`](sdk/python) (`pip install underwrit-client`, stdlib only — twenty lines to
integrate a runtime), [`sdk/typescript`](sdk/typescript), and `python3 -m underwrit mcp` for any
MCP-capable agent. Adapters for Claude Code (`underwrit-hook`), the OpenAI Agents SDK, LangGraph,
Google ADK, the Vercel AI SDK, gateways with an HTTP pre-call hook, and OpenTelemetry.

Measured on AgentDojo with real tool outputs, over the 501 attack pairs in which the injection
produced an action: the authority check catches 436 (87%) where taint alone caught 356, and 496
(99%) with domain sinks declared; an attacker who renames the sink to an unknown tool is still
caught 397 times where a name-based check caught 21. On tasks where the user named every value
(deploys, transfers to a named account) it interrupted none of 18 sink calls; on open tasks ("read
my inbox and reply") it still asks a person most of the time, so shadow mode stays the default
there. `docs/RESEARCH.md` has the tables, the kill criteria and their limits.

Evidence is signed (Ed25519), Merkle-proven (RFC 9162), cosigned by as many witnesses as you
configure (C2SP), and time-stamped by an RFC 3161 authority and OpenTimestamps when configured;
`verify.py` in every bundle checks all of it with the standard library. Sign-in is OpenID Connect
(RS256/ES256/EdDSA) or SPIFFE JWT-SVIDs; policy can be tightened by OPA or imported from Cedar.

## Who may act

Four roles, and the separation is the point: an **agent** proposes, an **approver** answers (two
distinct people in production), an **auditor** reads, an **admin** sets policy and mints tokens
and cannot approve. People sign in through your identity provider (OpenID Connect; a Keycloak
realm ships in `deploy/keycloak`) or with a token minted from the data plane; runtimes use tokens
or client credentials. When the provider is down, `underwrit break-glass` mints a short-lived emergency
credential from the host, and every request it makes is recorded under the operator's name.

## Shadow mode is the default, and that is a finding not a caution

Measured against [AgentDojo](https://github.com/ethz-spylab/agentdojo) — 123 traces authored by
people who had never seen this rule — session taint caught 16 of 26 attacks while interrupting 26 of
97 benign sessions: **one interrupt per 2.2 legitimate sink calls**, against a target of one in
twenty. Tuning detection up to 88% took it to one in 1.2. Detection and false positives moved
together, which is what a signal that does not discriminate looks like.

So the engine decides on every action and records it, and `enforce` decides whether a caller is told
to act on the verdict. Off unless an operator turns it on, per environment, after looking at their
own traffic — which is what the console's Policy view shows them before they apply a change.

## What the evidence proves

That no entry present has been altered or reordered, no file edited, and — format 2 — that each
entry sits at its stated position in a log of the stated size, under a checkpoint signed with the
data plane's Ed25519 key. **Not** that the record is complete — only the entries present can be
checked. And not origin from the bundle alone: pin the key from `GET /v1/keys` when you verify.
Every pack says so in its own README, and `verify.py` says it again when you run it.

Arguments expire (`UNDERWRIT_RETENTION_DAYS`, per environment); the chain holds digests and is untouched,
so a pack built after a sweep verifies exactly as one built before. A legal hold exempts a subject
and is visible in its pack.

The format is in [`spec/evidence-format.md`](spec/evidence-format.md), with test vectors. It and the
verifier are MIT: an evidence format only its vendor can read is not evidence.

## Layout

```
underwrit/
  chain.py      hash-chained log — append, verify, redact, seal
  evidence.py   pack builder + the offline verifier
  subject.py    what a pack is about; the library never queries a host table
  policy.py     the decision engine: taint and authority
  merkle.py     RFC 9162 tree and proofs
  ed25519.py    RFC 8032, pure Python
  checkpoint.py the log key and signed checkpoints
  replay.py     the shadow report
  store.py      sessions and decisions; arguments live here, digests go in the chain
  retention.py  sweeps, legal holds, the policy in force
  auth.py       roles, tokens (expiry, rotation, tenant, break glass), quorum
  oidc.py       OpenID Connect verification and role mapping
  api.py        data plane
  control.py    control plane — tenants, policy, fleet, usage, quota
  migrate.py    versioned schema
  backup.py     backups that end in verify_chain()
  obs.py        structured logs (never an argument) and /metrics
  ratelimit.py  per-token token bucket
  mcp.py        the MCP entry point
  recorder.py   139-line reference recorder over a Store protocol
  bundle.py     zip assembly and the signed manifest; db.py the SQLite/Postgres layer
  tsa.py        RFC 3161 and OpenTimestamps; ecdsa_p256.py ES256; opa.py, cedar.py the policy plane
  taskdraft.py  a task policy drafted from the request without a model; demo.py, demo_stack.py
sdk/            Python and TypeScript clients
spec/           the format, versioned, with vectors
docs/           OPERATIONS, THREAT-MODEL, RESEARCH, OTEL-PROPOSAL (the console, deployment and runbooks are in underwrit-io/controlplane)
tests/          245 tests; the new modules run each plane as a subprocess
```
