underwrit-client 0.0.0-stage → 0.4.0
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.
- package/LICENSE +21 -0
- package/README.md +54 -2
- package/dist/durable.d.ts +75 -0
- package/dist/durable.js +118 -0
- package/dist/index.d.ts +213 -0
- package/dist/index.js +184 -0
- package/dist/vercel.d.ts +22 -0
- package/dist/vercel.js +19 -0
- package/package.json +52 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Underwrit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# underwrit-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Client for an [Underwrit](https://underwrit.io) data plane, for agent runtimes in TypeScript
|
|
4
|
+
and JavaScript. `fetch`-based, no dependencies, Node 18+ or any modern runtime.
|
|
5
|
+
|
|
6
|
+
Underwrit decides whether an agent's tool call may run, holds it for a person when it should, and hands
|
|
7
|
+
back hash-chained, signed evidence of what happened. This package is the twenty lines between your
|
|
8
|
+
runtime and that API.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { Underwrit, Held } from "underwrit-client";
|
|
12
|
+
|
|
13
|
+
const underwrit = new Underwrit("https://underwrit.example.com", process.env.UNDERWRIT_AGENT_TOKEN!);
|
|
14
|
+
const s = await underwrit.session({ agent: "ops-assistant", environment: "production",
|
|
15
|
+
intent: "Restart api during incident 4417" });
|
|
16
|
+
|
|
17
|
+
const logs = await s.call("k8s/get_k8s_logs", getLogs, { pod: "api-1" });
|
|
18
|
+
try {
|
|
19
|
+
await s.call("k8s/rollout_restart", restart, { deployment: "api", namespace: "prod" });
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e instanceof Held) {
|
|
22
|
+
park(e.decision.id); // a person answers in the console, then:
|
|
23
|
+
await s.resume(e.decision.id, restart, { deployment: "api", namespace: "prod" },
|
|
24
|
+
{ verify: (r) => ({ status: "passed", checks: [{ name: "rollout", ok: true }] }) });
|
|
25
|
+
} else throw e;
|
|
26
|
+
}
|
|
27
|
+
await s.close();
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `call()` decides, runs the function, and records the outcome. It throws `Held` when the data
|
|
31
|
+
plane says `act: "hold"` (enforcement on, verdict held or denied). In shadow mode nothing is
|
|
32
|
+
thrown and the verdict is still recorded.
|
|
33
|
+
- `resume()` claims an approval with the same arguments immediately before executing. The claim is
|
|
34
|
+
refused if the arguments or stated preconditions changed, the approval expired, the policy now
|
|
35
|
+
refuses it, or it was already used.
|
|
36
|
+
- Errors whose message says "timeout" are recorded as `uncertain`; the data plane refuses a blind
|
|
37
|
+
retry until someone reconciles it.
|
|
38
|
+
- `verify` records what was observed afterwards, separately from whether the call succeeded.
|
|
39
|
+
- 429 and 503 are retried honouring `Retry-After` (three times, capped at thirty seconds).
|
|
40
|
+
- `decide(..., { signals: { taint: true, risk: 80, source: "my-classifier" } })` lets an external
|
|
41
|
+
classifier raise taint or risk; it can never lower them.
|
|
42
|
+
- Every decision carries `receipt.seq`; `underwrit.receipt(seq)` returns the Merkle inclusion proof and
|
|
43
|
+
the signed checkpoint once one covers the entry.
|
|
44
|
+
|
|
45
|
+
- `decide(..., { usage, delegation, components })` carries the 1.0 inputs; `preview()` asks the
|
|
46
|
+
gate without recording; `gates()` lists the stages and codes.
|
|
47
|
+
- `verify()`, `verificationsDue()`, `approvalLoad()`, `scopeHolds()` and `probeScopeHold()` map to
|
|
48
|
+
their routes one for one.
|
|
49
|
+
- `compensate(id, tool, args, reason)` proposes an undo for an action whose outcome is unknown. It
|
|
50
|
+
always goes to a person and does not clear the original's scope hold.
|
|
51
|
+
|
|
52
|
+
`underwrit-client/vercel` is a policy adapter for the Vercel AI SDK. The Python client, the MCP stdio
|
|
53
|
+
server and the REST reference are documented at https://underwrit.io.
|
|
54
|
+
|
|
55
|
+
MIT.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable suspension for TypeScript workflow engines: a held step stops the run instead of holding
|
|
3
|
+
* a process open, and the plane's signed resume wakes it.
|
|
4
|
+
*
|
|
5
|
+
* import { verifyResume, Resumer, proposeDurable, restateResolver, inngestSender } from "underwrit-client/durable";
|
|
6
|
+
*
|
|
7
|
+
* // In a step: decide, and on a hold park it under the engine's key.
|
|
8
|
+
* const d = await proposeDurable(session, "bank/send_money", args, { kind: "inngest", key: runId, resumeUrl });
|
|
9
|
+
* if (d.act === "hold") {
|
|
10
|
+
* const ev = await step.waitForEvent("underwrit", { event: INNGEST_EVENT, timeout: "24h",
|
|
11
|
+
* if: `async.data.decision == '${d.id}'` });
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* // In the web handler the plane calls (UNDERWRIT_RESUME_ALLOW):
|
|
15
|
+
* const resumer = new Resumer(uw, process.env.UNDERWRIT_RESUME_WEBHOOK_SECRET!)
|
|
16
|
+
* .on("restate", restateResolver("http://restate:8080"))
|
|
17
|
+
* .on("inngest", inngestSender(process.env.INNGEST_EVENT_KEY!));
|
|
18
|
+
* await resumer.handle(rawBody, headers);
|
|
19
|
+
*
|
|
20
|
+
* A resume is acknowledged after its handler returns, so one delivered twice resumes once, and the
|
|
21
|
+
* executing step claims the approval with the same arguments before it runs. MIT, like the client.
|
|
22
|
+
*/
|
|
23
|
+
import type { Session, Underwrit } from "./index.js";
|
|
24
|
+
export declare const INNGEST_EVENT = "underwrit/decision.resolved";
|
|
25
|
+
export interface Resume {
|
|
26
|
+
event: "decision.resume";
|
|
27
|
+
decision: string;
|
|
28
|
+
kind: string;
|
|
29
|
+
key: string;
|
|
30
|
+
state?: Record<string, unknown>;
|
|
31
|
+
outcome: "approved" | "denied" | "expired";
|
|
32
|
+
}
|
|
33
|
+
export declare class BadResume extends Error {
|
|
34
|
+
}
|
|
35
|
+
/** The resume, if the plane signed it (HMAC-SHA256 over `timestamp.body`) within `toleranceS`. */
|
|
36
|
+
export declare function verifyResume(body: string, headers: Record<string, string | undefined>, secret: string, toleranceS?: number, nowS?: number): Promise<Resume>;
|
|
37
|
+
export type Handler = (resume: Resume) => unknown | Promise<unknown>;
|
|
38
|
+
export declare class Resumer {
|
|
39
|
+
private client;
|
|
40
|
+
private secret;
|
|
41
|
+
private handlers;
|
|
42
|
+
constructor(client: Underwrit, secret?: string);
|
|
43
|
+
on(kind: string, handler: Handler): this;
|
|
44
|
+
dispatch(resume: Resume): Promise<{
|
|
45
|
+
result: unknown;
|
|
46
|
+
alreadyAcked: boolean;
|
|
47
|
+
}>;
|
|
48
|
+
handle(body: string, headers: Record<string, string | undefined>): Promise<{
|
|
49
|
+
result: unknown;
|
|
50
|
+
alreadyAcked: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
/** On start-up: every settled, unacknowledged park, for resumes whose webhook was lost. */
|
|
53
|
+
catchUp(): Promise<{
|
|
54
|
+
result: unknown;
|
|
55
|
+
alreadyAcked: boolean;
|
|
56
|
+
}[]>;
|
|
57
|
+
}
|
|
58
|
+
/** Decide; on a hold, park it under the engine's key and return at once. */
|
|
59
|
+
export declare function proposeDurable(session: Session, tool: string, args: Record<string, unknown>, park: {
|
|
60
|
+
kind: string;
|
|
61
|
+
key: string;
|
|
62
|
+
resumeUrl?: string;
|
|
63
|
+
}): Promise<import("./index.js").Decision>;
|
|
64
|
+
/** Restate: resolve the awakeable the handler is suspended on (`key` is the awakeable id). */
|
|
65
|
+
export declare function restateResolver(ingress: string, fetchImpl?: typeof fetch): Handler;
|
|
66
|
+
/** Inngest: send the event `step.waitForEvent` matches on `async.data.decision`. */
|
|
67
|
+
export declare function inngestSender(eventKey: string, base?: string, fetchImpl?: typeof fetch): Handler;
|
|
68
|
+
/** Temporal (TypeScript SDK): signal the workflow; `client` is a `@temporalio/client` Client. */
|
|
69
|
+
export declare function temporalSignaller(client: {
|
|
70
|
+
workflow: {
|
|
71
|
+
getHandle(id: string): {
|
|
72
|
+
signal(name: string, arg: unknown): Promise<void>;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
}, signal?: string): Handler;
|
package/dist/durable.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const INNGEST_EVENT = "underwrit/decision.resolved";
|
|
2
|
+
export class BadResume extends Error {
|
|
3
|
+
}
|
|
4
|
+
async function subtle() {
|
|
5
|
+
const g = globalThis.crypto;
|
|
6
|
+
if (g?.subtle)
|
|
7
|
+
return g.subtle;
|
|
8
|
+
const mod = await import("node:" + "crypto"); // Node 18 without the global
|
|
9
|
+
return mod.webcrypto.subtle;
|
|
10
|
+
}
|
|
11
|
+
function hex(buf) {
|
|
12
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
13
|
+
}
|
|
14
|
+
function same(a, b) {
|
|
15
|
+
if (a.length !== b.length)
|
|
16
|
+
return false;
|
|
17
|
+
let diff = 0;
|
|
18
|
+
for (let i = 0; i < a.length; i++)
|
|
19
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
20
|
+
return diff === 0;
|
|
21
|
+
}
|
|
22
|
+
/** The resume, if the plane signed it (HMAC-SHA256 over `timestamp.body`) within `toleranceS`. */
|
|
23
|
+
export async function verifyResume(body, headers, secret, toleranceS = 300, nowS = Date.now() / 1000) {
|
|
24
|
+
const h = {};
|
|
25
|
+
for (const [k, v] of Object.entries(headers))
|
|
26
|
+
if (v !== undefined)
|
|
27
|
+
h[k.toLowerCase()] = String(v);
|
|
28
|
+
if (secret) {
|
|
29
|
+
const ts = h["x-underwrit-timestamp"] ?? "", sig = h["x-underwrit-signature"] ?? "";
|
|
30
|
+
if (!ts || !sig.startsWith("sha256="))
|
|
31
|
+
throw new BadResume("unsigned resume");
|
|
32
|
+
const s = await subtle();
|
|
33
|
+
const key = await s.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
34
|
+
const want = hex(await s.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`)));
|
|
35
|
+
if (!same(want, sig.slice(7)))
|
|
36
|
+
throw new BadResume("signature does not verify");
|
|
37
|
+
if (!(Math.abs(nowS - Number(ts)) <= toleranceS))
|
|
38
|
+
throw new BadResume("resume is too old");
|
|
39
|
+
}
|
|
40
|
+
const doc = JSON.parse(body);
|
|
41
|
+
if (doc.event !== "decision.resume")
|
|
42
|
+
throw new BadResume("not a resume");
|
|
43
|
+
return doc;
|
|
44
|
+
}
|
|
45
|
+
export class Resumer {
|
|
46
|
+
client;
|
|
47
|
+
secret;
|
|
48
|
+
handlers = new Map();
|
|
49
|
+
constructor(client, secret = "") {
|
|
50
|
+
this.client = client;
|
|
51
|
+
this.secret = secret;
|
|
52
|
+
}
|
|
53
|
+
on(kind, handler) { this.handlers.set(kind, handler); return this; }
|
|
54
|
+
async dispatch(resume) {
|
|
55
|
+
const handler = this.handlers.get(resume.kind) ?? this.handlers.get("*");
|
|
56
|
+
if (!handler)
|
|
57
|
+
throw new Error(`no handler for ${resume.kind} resumes`);
|
|
58
|
+
const result = await handler(resume);
|
|
59
|
+
const ack = await this.client.ackParked(resume.decision);
|
|
60
|
+
return { result, alreadyAcked: Boolean(ack.alreadyAcked) };
|
|
61
|
+
}
|
|
62
|
+
async handle(body, headers) {
|
|
63
|
+
return this.dispatch(await verifyResume(body, headers, this.secret));
|
|
64
|
+
}
|
|
65
|
+
/** On start-up: every settled, unacknowledged park, for resumes whose webhook was lost. */
|
|
66
|
+
async catchUp() {
|
|
67
|
+
const { parked } = await this.client.parked({ settled: true });
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const p of parked ?? []) {
|
|
70
|
+
if (p.ackedAt)
|
|
71
|
+
continue;
|
|
72
|
+
out.push(await this.dispatch({ event: "decision.resume", decision: String(p.decision), kind: String(p.kind),
|
|
73
|
+
key: String(p.key), state: p.state,
|
|
74
|
+
outcome: String(p.outcome) }));
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Decide; on a hold, park it under the engine's key and return at once. */
|
|
80
|
+
export async function proposeDurable(session, tool, args, park) {
|
|
81
|
+
const d = await session.underwrit.decide(session.id, tool, args);
|
|
82
|
+
if (d.act === "hold")
|
|
83
|
+
await session.underwrit.park(d.id, park);
|
|
84
|
+
return d;
|
|
85
|
+
}
|
|
86
|
+
/** Restate: resolve the awakeable the handler is suspended on (`key` is the awakeable id). */
|
|
87
|
+
export function restateResolver(ingress, fetchImpl = fetch) {
|
|
88
|
+
const base = ingress.replace(/\/+$/, "");
|
|
89
|
+
return async (r) => {
|
|
90
|
+
const res = await fetchImpl(`${base}/restate/awakeables/${encodeURIComponent(r.key)}/resolve`, {
|
|
91
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify({ decision: r.decision, outcome: r.outcome })
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok)
|
|
95
|
+
throw new Error(`restate answered ${res.status}`);
|
|
96
|
+
return r.key;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Inngest: send the event `step.waitForEvent` matches on `async.data.decision`. */
|
|
100
|
+
export function inngestSender(eventKey, base = "https://inn.gs", fetchImpl = fetch) {
|
|
101
|
+
return async (r) => {
|
|
102
|
+
const res = await fetchImpl(`${base.replace(/\/+$/, "")}/e/${eventKey}`, {
|
|
103
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
104
|
+
body: JSON.stringify({ name: INNGEST_EVENT, id: `underwrit-${r.decision}`,
|
|
105
|
+
data: { decision: r.decision, outcome: r.outcome, run: r.key } })
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok)
|
|
108
|
+
throw new Error(`inngest answered ${res.status}`);
|
|
109
|
+
return r.key;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Temporal (TypeScript SDK): signal the workflow; `client` is a `@temporalio/client` Client. */
|
|
113
|
+
export function temporalSignaller(client, signal = "underwrit_resume") {
|
|
114
|
+
return async (r) => {
|
|
115
|
+
await client.workflow.getHandle(r.key).signal(signal, { decision: r.decision, outcome: r.outcome });
|
|
116
|
+
return r.key;
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** underwrit-client for TypeScript. fetch-based, no dependencies.
|
|
2
|
+
*
|
|
3
|
+
* Mirrors the Python client: `call()` decides, runs, and records; it throws `Held` when the data
|
|
4
|
+
* plane says `act: "hold"`. `resume()` claims an approval with the same arguments before running. */
|
|
5
|
+
export interface Decision {
|
|
6
|
+
id: string;
|
|
7
|
+
session: string;
|
|
8
|
+
verdict: string;
|
|
9
|
+
reasons: string[];
|
|
10
|
+
enforced: boolean;
|
|
11
|
+
taint: string[];
|
|
12
|
+
kinds: string[];
|
|
13
|
+
act: "proceed" | "hold";
|
|
14
|
+
argsDigest: string;
|
|
15
|
+
policyVersion: number;
|
|
16
|
+
duplicate?: boolean;
|
|
17
|
+
budgeted?: boolean;
|
|
18
|
+
risk?: number;
|
|
19
|
+
authority?: {
|
|
20
|
+
status: string;
|
|
21
|
+
fields: string[];
|
|
22
|
+
};
|
|
23
|
+
receipt?: {
|
|
24
|
+
seq: number;
|
|
25
|
+
hash: string;
|
|
26
|
+
prevHash?: string;
|
|
27
|
+
proof: string;
|
|
28
|
+
};
|
|
29
|
+
codes?: string[];
|
|
30
|
+
scope?: string;
|
|
31
|
+
hold?: string | null;
|
|
32
|
+
compensates?: string;
|
|
33
|
+
basis?: string;
|
|
34
|
+
}
|
|
35
|
+
/** The 1.0 decide inputs. Recorded as names and digests in the chain, never values. */
|
|
36
|
+
export interface V1Inputs {
|
|
37
|
+
/** What this call costs, by unit: `{ usd: 0.42, tokens: 1800 }`. Spend limits sum these. */
|
|
38
|
+
usage?: Record<string, number>;
|
|
39
|
+
/** The agents that proposed the call, outermost first. */
|
|
40
|
+
delegation?: (string | Record<string, unknown>)[];
|
|
41
|
+
/** What the agent is built from: model, prompt digest, tool versions. */
|
|
42
|
+
components?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
export interface DecideExtra extends V1Inputs {
|
|
45
|
+
preconditions?: unknown;
|
|
46
|
+
idempotencyKey?: string;
|
|
47
|
+
signals?: Signals;
|
|
48
|
+
scope?: string;
|
|
49
|
+
toolDefinitionDigest?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface Preview extends Omit<Decision, "id" | "session" | "argsDigest"> {
|
|
52
|
+
preview: true;
|
|
53
|
+
recorded: false;
|
|
54
|
+
stages: string[];
|
|
55
|
+
}
|
|
56
|
+
export interface Gates {
|
|
57
|
+
stages: {
|
|
58
|
+
stage: string;
|
|
59
|
+
asks: string;
|
|
60
|
+
codes: {
|
|
61
|
+
code: string;
|
|
62
|
+
meaning: string;
|
|
63
|
+
}[];
|
|
64
|
+
}[];
|
|
65
|
+
}
|
|
66
|
+
export interface Signals {
|
|
67
|
+
taint?: boolean;
|
|
68
|
+
risk?: number;
|
|
69
|
+
source?: string;
|
|
70
|
+
}
|
|
71
|
+
export interface Receipt {
|
|
72
|
+
seq: number;
|
|
73
|
+
pending?: boolean;
|
|
74
|
+
leafIndex?: number;
|
|
75
|
+
treeSize?: number;
|
|
76
|
+
root?: string;
|
|
77
|
+
proof?: string[];
|
|
78
|
+
checkpoint?: string;
|
|
79
|
+
keyId?: string;
|
|
80
|
+
witnessed?: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface Claim {
|
|
83
|
+
ok: boolean;
|
|
84
|
+
act: "proceed" | "hold";
|
|
85
|
+
enforced: boolean;
|
|
86
|
+
reason: string;
|
|
87
|
+
decision: Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
export interface Verification {
|
|
90
|
+
status: "passed" | "failed" | "not_run";
|
|
91
|
+
summary?: string;
|
|
92
|
+
checks?: {
|
|
93
|
+
name: string;
|
|
94
|
+
ok: boolean;
|
|
95
|
+
}[];
|
|
96
|
+
}
|
|
97
|
+
export declare class UnderwritError extends Error {
|
|
98
|
+
status: number;
|
|
99
|
+
constructor(status: number, message: string);
|
|
100
|
+
}
|
|
101
|
+
export declare class Held extends Error {
|
|
102
|
+
decision: Decision | Record<string, unknown>;
|
|
103
|
+
reason: string;
|
|
104
|
+
/** What to tell the model: don't retry, tell the user it waits for approval. Safe to show it. */
|
|
105
|
+
guidance: {
|
|
106
|
+
act?: string;
|
|
107
|
+
decisionId?: string;
|
|
108
|
+
retry?: string;
|
|
109
|
+
instruction?: string;
|
|
110
|
+
};
|
|
111
|
+
constructor(decision: Decision | Record<string, unknown>, reason?: string);
|
|
112
|
+
}
|
|
113
|
+
export declare class Underwrit {
|
|
114
|
+
private url;
|
|
115
|
+
private token;
|
|
116
|
+
private fetchImpl;
|
|
117
|
+
private opts;
|
|
118
|
+
constructor(url: string, token: string, fetchImpl?: typeof fetch, opts?: {
|
|
119
|
+
retries?: number;
|
|
120
|
+
maxRetryWaitMs?: number;
|
|
121
|
+
});
|
|
122
|
+
/** 429 and 503 are retried, honouring Retry-After, up to `retries` (default 3) times. */
|
|
123
|
+
private call;
|
|
124
|
+
session(opts?: {
|
|
125
|
+
session?: string;
|
|
126
|
+
agent?: string;
|
|
127
|
+
environment?: string;
|
|
128
|
+
intent?: string;
|
|
129
|
+
actor?: string;
|
|
130
|
+
taskPolicy?: Record<string, unknown>;
|
|
131
|
+
}): Promise<Session>;
|
|
132
|
+
decide(session: string, tool: string, args?: Record<string, unknown>, extra?: DecideExtra): Promise<Decision>;
|
|
133
|
+
/** "Ask the gate": what this would get now, recording nothing. Against `session`, or a
|
|
134
|
+
* hypothetical one (`environment`, `intent`, `taint`, `taskPolicy`); `policy` is a candidate. */
|
|
135
|
+
preview(tool: string, args?: Record<string, unknown>, on?: V1Inputs & {
|
|
136
|
+
session?: string;
|
|
137
|
+
environment?: string;
|
|
138
|
+
intent?: string;
|
|
139
|
+
taint?: string[];
|
|
140
|
+
taskPolicy?: Record<string, unknown>;
|
|
141
|
+
policy?: Record<string, unknown>;
|
|
142
|
+
}): Promise<Preview>;
|
|
143
|
+
/** The stages the gate asks, in order, and every code a decision can carry. */
|
|
144
|
+
gates(): Promise<Gates>;
|
|
145
|
+
/** A verification that lands after the outcome, inside the policy's post-check window. */
|
|
146
|
+
verify(decision: string, status: "passed" | "failed" | "inconclusive", opts?: {
|
|
147
|
+
checks?: {
|
|
148
|
+
name: string;
|
|
149
|
+
ok: boolean;
|
|
150
|
+
}[];
|
|
151
|
+
summary?: string;
|
|
152
|
+
}): Promise<Record<string, unknown>>;
|
|
153
|
+
verificationsDue(includeVerified?: boolean): Promise<{
|
|
154
|
+
checks: Record<string, unknown>[];
|
|
155
|
+
}>;
|
|
156
|
+
approvalLoad(days?: number): Promise<Record<string, unknown>>;
|
|
157
|
+
/** Propose an undo or repair for a decision whose outcome is uncertain, whose verification
|
|
158
|
+
* failed, or whose target it holds. Always goes to a person; claim it with the same arguments
|
|
159
|
+
* once approved. It does not clear the original's hold. */
|
|
160
|
+
compensate(decisionId: string, tool: string, args: Record<string, unknown>, reason: string, extra?: {
|
|
161
|
+
idempotencyKey?: string;
|
|
162
|
+
toolDefinitionDigest?: string;
|
|
163
|
+
}): Promise<Decision>;
|
|
164
|
+
scopeHolds(includeCleared?: boolean): Promise<{
|
|
165
|
+
holds: Record<string, unknown>[];
|
|
166
|
+
clearsBy: string[];
|
|
167
|
+
}>;
|
|
168
|
+
probeScopeHold(holdId: string, status: string, detail: string): Promise<Record<string, unknown>>;
|
|
169
|
+
/** The per-entry receipt: `pending` until a signed checkpoint covers the entry. */
|
|
170
|
+
receipt(seq: number): Promise<Receipt>;
|
|
171
|
+
claim(decisionId: string, args?: Record<string, unknown>, preconditions?: unknown): Promise<Claim>;
|
|
172
|
+
outcome(decision: string, status: string, detail?: string, verification?: Verification, reconcile?: boolean): Promise<{
|
|
173
|
+
seq: number;
|
|
174
|
+
hash: string;
|
|
175
|
+
taint: string[];
|
|
176
|
+
}>;
|
|
177
|
+
decision(id: string): Promise<Record<string, unknown>>;
|
|
178
|
+
/** Resolve when a held decision is answered (or expires), or after `timeoutMs`. Long-polls, at
|
|
179
|
+
* most a minute per request. `lifecycle` on the result: `approved` → claim and run. */
|
|
180
|
+
wait(id: string, timeoutMs?: number): Promise<Record<string, unknown>>;
|
|
181
|
+
/** Hand a held decision to the plane and stop waiting: the plane calls `resumeUrl` when it is
|
|
182
|
+
* answered (see `underwrit-client/durable`). */
|
|
183
|
+
park(decisionId: string, opts: {
|
|
184
|
+
kind: string;
|
|
185
|
+
key: string;
|
|
186
|
+
resumeUrl?: string;
|
|
187
|
+
state?: Record<string, unknown>;
|
|
188
|
+
}): Promise<Record<string, unknown>>;
|
|
189
|
+
parked(opts?: {
|
|
190
|
+
settled?: boolean;
|
|
191
|
+
kind?: string;
|
|
192
|
+
}): Promise<{
|
|
193
|
+
parked: Record<string, unknown>[];
|
|
194
|
+
}>;
|
|
195
|
+
ackParked(decisionId: string): Promise<Record<string, unknown>>;
|
|
196
|
+
close(session: string, status?: string): Promise<unknown>;
|
|
197
|
+
evidence(session: string): Promise<Record<string, unknown>>;
|
|
198
|
+
}
|
|
199
|
+
export declare class Session {
|
|
200
|
+
underwrit: Underwrit;
|
|
201
|
+
id: string;
|
|
202
|
+
constructor(underwrit: Underwrit, id: string);
|
|
203
|
+
call<R>(tool: string, fn: (args: Record<string, unknown>) => Promise<R> | R, args?: Record<string, unknown>, opts?: DecideExtra & {
|
|
204
|
+
verify?: (r: R) => Verification;
|
|
205
|
+
}): Promise<R>;
|
|
206
|
+
resume<R>(decisionId: string, fn: (args: Record<string, unknown>) => Promise<R> | R, args?: Record<string, unknown>, opts?: {
|
|
207
|
+
preconditions?: unknown;
|
|
208
|
+
verify?: (r: R) => Verification;
|
|
209
|
+
}): Promise<R>;
|
|
210
|
+
private run;
|
|
211
|
+
preview(tool: string, args?: Record<string, unknown>, v1?: V1Inputs): Promise<Preview>;
|
|
212
|
+
close(status?: string): Promise<unknown>;
|
|
213
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/** underwrit-client for TypeScript. fetch-based, no dependencies.
|
|
2
|
+
*
|
|
3
|
+
* Mirrors the Python client: `call()` decides, runs, and records; it throws `Held` when the data
|
|
4
|
+
* plane says `act: "hold"`. `resume()` claims an approval with the same arguments before running. */
|
|
5
|
+
export class UnderwritError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(`${status}: ${message}`);
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class Held extends Error {
|
|
13
|
+
decision;
|
|
14
|
+
reason;
|
|
15
|
+
/** What to tell the model: don't retry, tell the user it waits for approval. Safe to show it. */
|
|
16
|
+
guidance;
|
|
17
|
+
constructor(decision, reason = "") {
|
|
18
|
+
super(reason || (decision.reasons?.[0] ?? "held for a person"));
|
|
19
|
+
this.decision = decision;
|
|
20
|
+
this.reason = reason;
|
|
21
|
+
const g = decision.agentGuidance;
|
|
22
|
+
this.guidance = g && typeof g === "object" ? g : {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class Underwrit {
|
|
26
|
+
url;
|
|
27
|
+
token;
|
|
28
|
+
fetchImpl;
|
|
29
|
+
opts;
|
|
30
|
+
constructor(url, token, fetchImpl = fetch, opts = {}) {
|
|
31
|
+
this.url = url;
|
|
32
|
+
this.token = token;
|
|
33
|
+
this.fetchImpl = fetchImpl;
|
|
34
|
+
this.opts = opts;
|
|
35
|
+
this.url = url.replace(/\/+$/, "");
|
|
36
|
+
}
|
|
37
|
+
/** 429 and 503 are retried, honouring Retry-After, up to `retries` (default 3) times. */
|
|
38
|
+
async call(method, path, body) {
|
|
39
|
+
const retries = this.opts.retries ?? 3;
|
|
40
|
+
const cap = this.opts.maxRetryWaitMs ?? 30_000;
|
|
41
|
+
for (let attempt = 0;; attempt++) {
|
|
42
|
+
const r = await this.fetchImpl(`${this.url}${path}`, {
|
|
43
|
+
method,
|
|
44
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.token}` },
|
|
45
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
46
|
+
});
|
|
47
|
+
if (r.ok)
|
|
48
|
+
return (await r.json());
|
|
49
|
+
let payload = {};
|
|
50
|
+
try {
|
|
51
|
+
payload = (await r.json());
|
|
52
|
+
}
|
|
53
|
+
catch { /* no body */ }
|
|
54
|
+
if ((r.status === 429 || r.status === 503) && attempt < retries) {
|
|
55
|
+
const header = Number(r.headers.get("Retry-After") ?? payload.retryAfterSeconds ?? 0);
|
|
56
|
+
const wait = Math.min(cap, (header > 0 ? header * 1000 : Math.min(2 ** (attempt + 1) * 1000, cap)));
|
|
57
|
+
await new Promise((res) => setTimeout(res, wait));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
throw new UnderwritError(r.status, payload.error ?? r.statusText);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async session(opts = {}) {
|
|
64
|
+
const s = await this.call("POST", "/v1/sessions", opts);
|
|
65
|
+
return new Session(this, s.id);
|
|
66
|
+
}
|
|
67
|
+
decide(session, tool, args = {}, extra = {}) {
|
|
68
|
+
return this.call("POST", "/v1/decide", { session, tool, arguments: args, ...extra });
|
|
69
|
+
}
|
|
70
|
+
/** "Ask the gate": what this would get now, recording nothing. Against `session`, or a
|
|
71
|
+
* hypothetical one (`environment`, `intent`, `taint`, `taskPolicy`); `policy` is a candidate. */
|
|
72
|
+
preview(tool, args = {}, on = {}) {
|
|
73
|
+
return this.call("POST", "/v1/preview", { tool, arguments: args, ...on });
|
|
74
|
+
}
|
|
75
|
+
/** The stages the gate asks, in order, and every code a decision can carry. */
|
|
76
|
+
gates() { return this.call("GET", "/v1/gates"); }
|
|
77
|
+
/** A verification that lands after the outcome, inside the policy's post-check window. */
|
|
78
|
+
verify(decision, status, opts = {}) {
|
|
79
|
+
return this.call("POST", "/v1/verify", { decision, status, ...opts });
|
|
80
|
+
}
|
|
81
|
+
verificationsDue(includeVerified = false) {
|
|
82
|
+
return this.call("GET", `/v1/verifications/due${includeVerified ? "?all=1" : ""}`);
|
|
83
|
+
}
|
|
84
|
+
approvalLoad(days = 7) { return this.call("GET", `/v1/approvals/load?days=${days}`); }
|
|
85
|
+
/** Propose an undo or repair for a decision whose outcome is uncertain, whose verification
|
|
86
|
+
* failed, or whose target it holds. Always goes to a person; claim it with the same arguments
|
|
87
|
+
* once approved. It does not clear the original's hold. */
|
|
88
|
+
compensate(decisionId, tool, args, reason, extra = {}) {
|
|
89
|
+
if (!reason.trim())
|
|
90
|
+
throw new Error("compensate() needs a reason; a person reads it before approving");
|
|
91
|
+
return this.call("POST", `/v1/decisions/${decisionId}/compensate`, { tool, arguments: args, reason, ...extra });
|
|
92
|
+
}
|
|
93
|
+
scopeHolds(includeCleared = false) {
|
|
94
|
+
return this.call("GET", `/v1/scope-holds${includeCleared ? "?all=1" : ""}`);
|
|
95
|
+
}
|
|
96
|
+
probeScopeHold(holdId, status, detail) {
|
|
97
|
+
return this.call("POST", `/v1/scope-holds/${holdId}/probe`, { status, detail });
|
|
98
|
+
}
|
|
99
|
+
/** The per-entry receipt: `pending` until a signed checkpoint covers the entry. */
|
|
100
|
+
receipt(seq) { return this.call("GET", `/v1/receipts/${seq}`); }
|
|
101
|
+
claim(decisionId, args, preconditions) {
|
|
102
|
+
const body = {};
|
|
103
|
+
if (args !== undefined)
|
|
104
|
+
body.arguments = args;
|
|
105
|
+
if (preconditions !== undefined)
|
|
106
|
+
body.preconditions = preconditions;
|
|
107
|
+
return this.call("POST", `/v1/decisions/${decisionId}/claim`, body);
|
|
108
|
+
}
|
|
109
|
+
outcome(decision, status, detail = "", verification, reconcile = false) {
|
|
110
|
+
return this.call("POST", "/v1/outcome", { decision, status, detail, ...(verification ? { verification } : {}), ...(reconcile ? { reconcile } : {}) });
|
|
111
|
+
}
|
|
112
|
+
decision(id) { return this.call("GET", `/v1/decisions/${id}`); }
|
|
113
|
+
/** Resolve when a held decision is answered (or expires), or after `timeoutMs`. Long-polls, at
|
|
114
|
+
* most a minute per request. `lifecycle` on the result: `approved` → claim and run. */
|
|
115
|
+
async wait(id, timeoutMs = 300_000) {
|
|
116
|
+
const end = Date.now() + Math.max(0, timeoutMs);
|
|
117
|
+
for (;;) {
|
|
118
|
+
const step = Math.max(0, Math.min(60, Math.floor((end - Date.now()) / 1000)));
|
|
119
|
+
const d = await this.call("GET", `/v1/decisions/${id}${step ? `?wait=${step}` : ""}`);
|
|
120
|
+
if (d.lifecycle !== "held" || step <= 1)
|
|
121
|
+
return d;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Hand a held decision to the plane and stop waiting: the plane calls `resumeUrl` when it is
|
|
125
|
+
* answered (see `underwrit-client/durable`). */
|
|
126
|
+
park(decisionId, opts) {
|
|
127
|
+
return this.call("POST", `/v1/decisions/${decisionId}/park`, { kind: opts.kind, key: opts.key, resumeUrl: opts.resumeUrl ?? "", state: opts.state ?? {} });
|
|
128
|
+
}
|
|
129
|
+
parked(opts = {}) {
|
|
130
|
+
const q = [...(opts.settled === undefined ? [] : [`settled=${opts.settled ? 1 : 0}`]), ...(opts.kind ? [`kind=${opts.kind}`] : [])];
|
|
131
|
+
return this.call("GET", `/v1/parked${q.length ? "?" + q.join("&") : ""}`);
|
|
132
|
+
}
|
|
133
|
+
ackParked(decisionId) { return this.call("POST", `/v1/parked/${decisionId}/ack`, {}); }
|
|
134
|
+
close(session, status = "succeeded") { return this.call("POST", `/v1/sessions/${session}/close`, { status }); }
|
|
135
|
+
evidence(session) { return this.call("GET", `/v1/evidence/${session}`); }
|
|
136
|
+
}
|
|
137
|
+
export class Session {
|
|
138
|
+
underwrit;
|
|
139
|
+
id;
|
|
140
|
+
constructor(underwrit, id) {
|
|
141
|
+
this.underwrit = underwrit;
|
|
142
|
+
this.id = id;
|
|
143
|
+
}
|
|
144
|
+
async call(tool, fn, args = {}, opts = {}) {
|
|
145
|
+
const { verify: _verify, ...extra } = opts;
|
|
146
|
+
const d = await this.underwrit.decide(this.id, tool, args, extra);
|
|
147
|
+
if (d.act === "hold")
|
|
148
|
+
throw new Held(d);
|
|
149
|
+
return this.run(d.id, fn, args, opts.verify);
|
|
150
|
+
}
|
|
151
|
+
async resume(decisionId, fn, args = {}, opts = {}) {
|
|
152
|
+
const c = await this.underwrit.claim(decisionId, args, opts.preconditions);
|
|
153
|
+
if (!c.ok && c.act === "hold")
|
|
154
|
+
throw new Held(c.decision, c.reason);
|
|
155
|
+
return this.run(decisionId, fn, args, opts.verify);
|
|
156
|
+
}
|
|
157
|
+
async run(decisionId, fn, args, verify) {
|
|
158
|
+
let result;
|
|
159
|
+
try {
|
|
160
|
+
result = await fn(args);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
const uncertain = e instanceof Error && /timeout|timed out/i.test(e.message);
|
|
164
|
+
await this.underwrit.outcome(decisionId, uncertain ? "uncertain" : "failed", String(e).slice(0, 2000));
|
|
165
|
+
throw e;
|
|
166
|
+
}
|
|
167
|
+
let verification;
|
|
168
|
+
if (verify) {
|
|
169
|
+
try {
|
|
170
|
+
verification = verify(result);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
verification = { status: "failed", summary: `verification threw ${String(e)}` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const detail = typeof result === "string" ? result : JSON.stringify(result ?? null).slice(0, 2000);
|
|
177
|
+
await this.underwrit.outcome(decisionId, "succeeded", detail, verification);
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
preview(tool, args = {}, v1 = {}) {
|
|
181
|
+
return this.underwrit.preview(tool, args, { ...v1, session: this.id });
|
|
182
|
+
}
|
|
183
|
+
close(status = "succeeded") { return this.underwrit.close(this.id, status); }
|
|
184
|
+
}
|
package/dist/vercel.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Vercel AI SDK: a policy adapter shaped like `@ai-sdk/policy-opa`, backed by an Underwrit session.
|
|
2
|
+
*
|
|
3
|
+
* const policy = underwritPolicy(session);
|
|
4
|
+
* tool({ ..., needsApproval: policy.needsApproval("bank/send_money") })
|
|
5
|
+
*
|
|
6
|
+
* `decide()` maps Underwrit's verdicts onto the four states the SDK's policy adapters use — allow,
|
|
7
|
+
* deny, requires-approval, not-applicable — and `shadow` mirrors the SDK's shadow wrapper: every
|
|
8
|
+
* decision is recorded, `onDecision` sees it, and nothing is blocked. */
|
|
9
|
+
import type { Session, Decision } from "./index";
|
|
10
|
+
export type PolicyState = "allow" | "deny" | "requires-approval" | "not-applicable";
|
|
11
|
+
export declare function underwritPolicy(session: Session, opts?: {
|
|
12
|
+
shadow?: boolean;
|
|
13
|
+
onDecision?: (d: Decision, state: PolicyState) => void;
|
|
14
|
+
}): {
|
|
15
|
+
decide(tool: string, args?: Record<string, unknown>): Promise<{
|
|
16
|
+
state: PolicyState;
|
|
17
|
+
decision: Decision;
|
|
18
|
+
}>;
|
|
19
|
+
needsApproval(tool: string): ({ input }: {
|
|
20
|
+
input: Record<string, unknown>;
|
|
21
|
+
}) => Promise<boolean>;
|
|
22
|
+
};
|
package/dist/vercel.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function underwritPolicy(session, opts = {}) {
|
|
2
|
+
const map = (d) => d.verdict === "deny" ? (d.act === "hold" ? "deny" : "allow")
|
|
3
|
+
: d.verdict === "require_human" ? (d.act === "hold" ? "requires-approval" : "allow")
|
|
4
|
+
: "allow";
|
|
5
|
+
return {
|
|
6
|
+
async decide(tool, args = {}) {
|
|
7
|
+
const d = await session.underwrit.decide(session.id, tool, args);
|
|
8
|
+
const state = opts.shadow ? "allow" : map(d);
|
|
9
|
+
opts.onDecision?.(d, state);
|
|
10
|
+
return { state, decision: d };
|
|
11
|
+
},
|
|
12
|
+
needsApproval(tool) {
|
|
13
|
+
return async ({ input }) => {
|
|
14
|
+
const { state } = await this.decide(tool, input);
|
|
15
|
+
return state === "requires-approval" || state === "deny";
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "underwrit-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Client for an Underwrit data plane: decide, claim, record, and produce evidence for what an agent did.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./vercel": {
|
|
15
|
+
"types": "./dist/vercel.d.ts",
|
|
16
|
+
"import": "./dist/vercel.js"
|
|
17
|
+
},
|
|
18
|
+
"./durable": {
|
|
19
|
+
"types": "./dist/durable.d.ts",
|
|
20
|
+
"import": "./dist/durable.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"ai-agents",
|
|
33
|
+
"mcp",
|
|
34
|
+
"approvals",
|
|
35
|
+
"audit",
|
|
36
|
+
"evidence",
|
|
37
|
+
"policy",
|
|
38
|
+
"prompt-injection"
|
|
39
|
+
],
|
|
40
|
+
"homepage": "https://underwrit.io",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"email": "admin@underwrit.io"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.json",
|
|
49
|
+
"prepublishOnly": "npm run build"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"typescript": "~5.7.2"
|
|
53
|
+
}
|
|
54
|
+
}
|