@decidio/sdk 0.1.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 +202 -0
- package/README.md +163 -0
- package/dist/adapters/_gate.d.ts +13 -0
- package/dist/adapters/_gate.js +60 -0
- package/dist/adapters/langgraph.d.ts +10 -0
- package/dist/adapters/langgraph.js +28 -0
- package/dist/adapters/openai.d.ts +26 -0
- package/dist/adapters/openai.js +45 -0
- package/dist/adapters/temporal.d.ts +16 -0
- package/dist/adapters/temporal.js +34 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +314 -0
- package/dist/guard.d.ts +52 -0
- package/dist/guard.js +76 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +197 -0
- package/dist/inngest.d.ts +61 -0
- package/dist/inngest.js +114 -0
- package/dist/resume.d.ts +152 -0
- package/dist/resume.js +301 -0
- package/dist/signing.d.ts +48 -0
- package/dist/signing.js +83 -0
- package/openapi.yaml +171 -0
- package/package.json +83 -0
package/dist/resume.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// @decidio/sdk — durable async resume.
|
|
2
|
+
//
|
|
3
|
+
// The hard problem behind a "one-line" agent gate: a real human approval takes
|
|
4
|
+
// minutes to days. Nobody watches a 60-second polling window. So a routed action
|
|
5
|
+
// must SUSPEND (the requesting process may even exit) and RESUME later when the
|
|
6
|
+
// approval arrives — and the AGENT must perform its own action on resume. Decidio
|
|
7
|
+
// stays the approval layer above: it authorizes, records, and (on approval) SIGNALS
|
|
8
|
+
// the agent. It never holds the agent's downstream credentials and never executes
|
|
9
|
+
// the write itself. This is the same shape as HumanLayer's webhooks, Temporal
|
|
10
|
+
// Signals, Inngest waitForEvent, and Decidio's own A2A `input-required` state.
|
|
11
|
+
//
|
|
12
|
+
// Two resume transports, same core:
|
|
13
|
+
// • webhookHandler() — production: Decidio POSTs a signed verdict to the agent's
|
|
14
|
+
// resume URL; the handler re-executes the agent's own fn and confirms.
|
|
15
|
+
// • worker() — no inbound URL (laptop/CLI): a long-lived watcher polls
|
|
16
|
+
// /agent/status for each pending decision, then re-executes on approval.
|
|
17
|
+
// Either way the args are replayed from an AGENT-SIDE store (Decidio holds none of
|
|
18
|
+
// the downstream payload — only the authority decision), and execution is single-use
|
|
19
|
+
// (the pending entry is deleted on completion, so a duplicate signal can't double-write).
|
|
20
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
21
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { signConfirm } from "./signing.js";
|
|
24
|
+
export class MemoryPendingStore {
|
|
25
|
+
m = new Map();
|
|
26
|
+
claimed = new Set();
|
|
27
|
+
async put(p) { this.m.set(p.decisionId, p); }
|
|
28
|
+
async get(id) { return this.m.get(id) ?? null; }
|
|
29
|
+
async delete(id) { this.m.delete(id); this.claimed.delete(id); }
|
|
30
|
+
async list() { return [...this.m.values()].filter((p) => !this.claimed.has(p.decisionId)); }
|
|
31
|
+
async claim(id) { if (this.claimed.has(id) || !this.m.has(id))
|
|
32
|
+
return false; this.claimed.add(id); return true; }
|
|
33
|
+
async release(id) { this.claimed.delete(id); }
|
|
34
|
+
}
|
|
35
|
+
/** File-backed store so a COLD process (the original requester long gone) can still
|
|
36
|
+
* resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
|
|
37
|
+
* renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
|
|
38
|
+
* sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
|
|
39
|
+
* TTL recovery — tracked in the backlog.) */
|
|
40
|
+
export class FilePendingStore {
|
|
41
|
+
dir;
|
|
42
|
+
constructor(dir) {
|
|
43
|
+
this.dir = dir;
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
47
|
+
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
48
|
+
claimedPath(id) { return join(this.dir, `${this.key(id)}.claimed`); }
|
|
49
|
+
async put(p) { writeFileSync(this.path(p.decisionId), JSON.stringify(p, null, 2)); }
|
|
50
|
+
async get(id) {
|
|
51
|
+
for (const f of [this.path(id), this.claimedPath(id)])
|
|
52
|
+
if (existsSync(f))
|
|
53
|
+
return JSON.parse(readFileSync(f, "utf8"));
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
async delete(id) { for (const f of [this.path(id), this.claimedPath(id)])
|
|
57
|
+
if (existsSync(f))
|
|
58
|
+
rmSync(f); }
|
|
59
|
+
async list() {
|
|
60
|
+
if (!existsSync(this.dir))
|
|
61
|
+
return [];
|
|
62
|
+
return readdirSync(this.dir).filter((f) => f.endsWith(".json")) // claimed (.claimed) excluded — in flight
|
|
63
|
+
.map((f) => JSON.parse(readFileSync(join(this.dir, f), "utf8")));
|
|
64
|
+
}
|
|
65
|
+
async claim(id) {
|
|
66
|
+
try {
|
|
67
|
+
renameSync(this.path(id), this.claimedPath(id));
|
|
68
|
+
return true;
|
|
69
|
+
} // atomic; fails if already claimed/gone
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async release(id) {
|
|
75
|
+
try {
|
|
76
|
+
renameSync(this.claimedPath(id), this.path(id));
|
|
77
|
+
}
|
|
78
|
+
catch { /* already released/gone */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Thrown by a `withApproval`-wrapped call when the action is routed and configured
|
|
82
|
+
* for async resume: the action is now SUSPENDED (parked in the store), not failed.
|
|
83
|
+
* The caller can stop here — the process may even exit; the resume transport will
|
|
84
|
+
* complete it later. */
|
|
85
|
+
export class DecidioSuspendedError extends Error {
|
|
86
|
+
decisionId;
|
|
87
|
+
action;
|
|
88
|
+
constructor(decisionId, action) {
|
|
89
|
+
super(`Decidio suspended "${action}" for async approval (decision ${decisionId}). It will resume when a human approves.`);
|
|
90
|
+
this.decisionId = decisionId;
|
|
91
|
+
this.action = action;
|
|
92
|
+
this.name = "DecidioSuspendedError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
96
|
+
function sign(secret, body) {
|
|
97
|
+
return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
|
|
98
|
+
}
|
|
99
|
+
/** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
|
|
100
|
+
* Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
|
|
101
|
+
export function verifyResumeSignature(secret, body, header) {
|
|
102
|
+
if (!header)
|
|
103
|
+
return false;
|
|
104
|
+
const expected = sign(secret, body);
|
|
105
|
+
const a = Buffer.from(expected), b = Buffer.from(header);
|
|
106
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
107
|
+
}
|
|
108
|
+
async function confirm(cfg, decisionId, capturedResponse) {
|
|
109
|
+
const f = cfg.fetchImpl ?? fetch;
|
|
110
|
+
// Sign the confirm so the DURABLE/resume path can earn application_confirmed too (bound to
|
|
111
|
+
// {action:"confirm", scope:decisionId}); without a key the server caps it at agent_asserted.
|
|
112
|
+
const requesterIdentity = signConfirm(decisionId, cfg);
|
|
113
|
+
await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
116
|
+
body: JSON.stringify({ decisionId, capturedResponse: capturedResponse ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
|
|
117
|
+
}).then((r) => { if (!r.ok)
|
|
118
|
+
throw new Error(`confirm ${r.status}`); });
|
|
119
|
+
}
|
|
120
|
+
async function statusOf(cfg, decisionId) {
|
|
121
|
+
const f = cfg.fetchImpl ?? fetch;
|
|
122
|
+
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
|
|
123
|
+
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
124
|
+
});
|
|
125
|
+
if (!r.ok)
|
|
126
|
+
throw new Error(`status ${r.status}`);
|
|
127
|
+
return r.json();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Build the resume controller. Spread it into your GuardConfig as `resume`, then
|
|
131
|
+
* either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
|
|
132
|
+
*
|
|
133
|
+
* const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
|
|
134
|
+
* const guard = { apiUrl, agentId, apiToken, resume };
|
|
135
|
+
* const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
|
|
136
|
+
* // production: http.createServer(resume.webhookHandler()).listen(4100)
|
|
137
|
+
* // or CLI: resume.worker()
|
|
138
|
+
*/
|
|
139
|
+
export function createDecidioResume(opts) {
|
|
140
|
+
const registry = new Map();
|
|
141
|
+
const inFlight = new Set(); // concurrent-duplicate guard (same process)
|
|
142
|
+
const secret = opts.webhookSecret ?? opts.apiToken ?? "";
|
|
143
|
+
const ctrl = {
|
|
144
|
+
...opts,
|
|
145
|
+
// Default the signing identity from env so a COLD resume process (a fresh worker that has
|
|
146
|
+
// the agent's env but wasn't passed config) still signs the confirm and earns the wrapper tier.
|
|
147
|
+
agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
|
|
148
|
+
agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
|
|
149
|
+
agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
|
|
150
|
+
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID, // D-B3.6: tenant-bind the cold-resume confirm proof
|
|
151
|
+
webhookSecret: secret,
|
|
152
|
+
store: opts.store,
|
|
153
|
+
register(action, fn) { registry.set(action, fn); },
|
|
154
|
+
async resolve({ decisionId, verdict, reason }) {
|
|
155
|
+
const finish = (o) => { if (o.status !== "unknown")
|
|
156
|
+
opts.onResolved?.(o); return o; };
|
|
157
|
+
const pending = await ctrl.store.get(decisionId);
|
|
158
|
+
if (!pending)
|
|
159
|
+
return { status: "unknown", decisionId }; // not ours / already resumed
|
|
160
|
+
if (verdict === "rejected") {
|
|
161
|
+
await ctrl.store.delete(decisionId);
|
|
162
|
+
return finish({ status: "rejected", decisionId, action: pending.action, reason });
|
|
163
|
+
}
|
|
164
|
+
if (verdict === "blocked") {
|
|
165
|
+
await ctrl.store.delete(decisionId);
|
|
166
|
+
return finish({ status: "blocked", decisionId, action: pending.action, reason });
|
|
167
|
+
}
|
|
168
|
+
// Fail CLOSED on any unexpected verdict — never execute on a garbage/unknown value.
|
|
169
|
+
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
170
|
+
return { status: "unknown", decisionId };
|
|
171
|
+
const fn = registry.get(pending.action);
|
|
172
|
+
if (!fn)
|
|
173
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" — call resume.register(...) at startup` });
|
|
174
|
+
// Atomically CLAIM before executing — the cross-process single-use guarantee. If
|
|
175
|
+
// the store has no claim primitive, fall back to an in-process guard (same-process).
|
|
176
|
+
const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
|
|
177
|
+
if (!claimed)
|
|
178
|
+
return { status: "unknown", decisionId }; // another worker owns it
|
|
179
|
+
try {
|
|
180
|
+
// Approved: the AGENT performs its own action FIRST. On SUCCESS clear the parked
|
|
181
|
+
// entry (single-use) + confirm. If it throws, RELEASE the claim so the entry stays
|
|
182
|
+
// parked + retryable — an approved action is never lost.
|
|
183
|
+
const result = await fn(...pending.args);
|
|
184
|
+
await ctrl.store.delete(decisionId);
|
|
185
|
+
try {
|
|
186
|
+
await confirm(ctrl, decisionId, result);
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
console.warn(`[decidio] resume confirm failed (action succeeded): ${e.message}`);
|
|
190
|
+
}
|
|
191
|
+
return finish({ status: "executed", decisionId, action: pending.action, result });
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
if (ctrl.store.release)
|
|
195
|
+
await ctrl.store.release(decisionId).catch(() => { });
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
inFlight.delete(decisionId);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
webhookHandler() {
|
|
203
|
+
return (req, res) => {
|
|
204
|
+
if (req.method !== "POST") {
|
|
205
|
+
res.statusCode = 405;
|
|
206
|
+
return res.end("method not allowed");
|
|
207
|
+
}
|
|
208
|
+
let raw = "";
|
|
209
|
+
req.on("data", (c) => (raw += c));
|
|
210
|
+
req.on("end", async () => {
|
|
211
|
+
try {
|
|
212
|
+
// Fail CLOSED when no secret is configured: an agent that forgot to set a
|
|
213
|
+
// webhookSecret must NOT be drivable by forged callbacks. Opt out only with
|
|
214
|
+
// allowUnsigned (trusted network boundary).
|
|
215
|
+
if (!secret) {
|
|
216
|
+
if (!opts.allowUnsigned) {
|
|
217
|
+
res.statusCode = 401;
|
|
218
|
+
return res.end(JSON.stringify({ error: "resume webhook unsigned and no secret configured (set webhookSecret or allowUnsigned)" }));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (!verifyResumeSignature(secret, raw, req.headers["x-decidio-signature"])) {
|
|
222
|
+
res.statusCode = 401;
|
|
223
|
+
return res.end(JSON.stringify({ error: "bad signature" }));
|
|
224
|
+
}
|
|
225
|
+
const body = JSON.parse(raw || "{}");
|
|
226
|
+
const verdict = body.verdict;
|
|
227
|
+
const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict, reason: body.reason });
|
|
228
|
+
res.statusCode = 200;
|
|
229
|
+
res.setHeader("content-type", "application/json");
|
|
230
|
+
res.end(JSON.stringify({ ok: true, outcome: { status: outcome.status, decisionId: outcome.decisionId } }));
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
res.statusCode = 500;
|
|
234
|
+
res.end(JSON.stringify({ error: e?.message ?? "resume failed" }));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
},
|
|
239
|
+
fetchHandler() {
|
|
240
|
+
return async (req) => {
|
|
241
|
+
if (req.method !== "POST")
|
|
242
|
+
return new Response("method not allowed", { status: 405 });
|
|
243
|
+
const raw = await req.text();
|
|
244
|
+
if (!secret) {
|
|
245
|
+
if (!opts.allowUnsigned)
|
|
246
|
+
return new Response(JSON.stringify({ error: "resume webhook unsigned and no secret configured (set webhookSecret or allowUnsigned)" }), { status: 401 });
|
|
247
|
+
}
|
|
248
|
+
else if (!verifyResumeSignature(secret, raw, req.headers.get("x-decidio-signature") ?? undefined)) {
|
|
249
|
+
return new Response(JSON.stringify({ error: "bad signature" }), { status: 401 });
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const body = JSON.parse(raw || "{}");
|
|
253
|
+
const outcome = await ctrl.resolve({ decisionId: String(body.decisionId), verdict: body.verdict, reason: body.reason });
|
|
254
|
+
return new Response(JSON.stringify({ ok: true, outcome: { status: outcome.status, decisionId: outcome.decisionId } }), { status: 200, headers: { "content-type": "application/json" } });
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
return new Response(JSON.stringify({ error: e?.message ?? "resume failed" }), { status: 500 });
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
worker(workerOpts) {
|
|
262
|
+
const interval = workerOpts?.intervalMs ?? 2_500;
|
|
263
|
+
const deadline = workerOpts?.timeoutMs ? Date.now() + workerOpts.timeoutMs : Infinity;
|
|
264
|
+
let stopped = false;
|
|
265
|
+
(async () => {
|
|
266
|
+
while (!stopped) {
|
|
267
|
+
const pendings = await ctrl.store.list().catch(() => []);
|
|
268
|
+
for (const p of pendings) {
|
|
269
|
+
let st;
|
|
270
|
+
try {
|
|
271
|
+
st = await statusOf(ctrl, p.decisionId);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const verdict = st.state === "approved" || st.state === "auto_approved" ? "approved" :
|
|
277
|
+
st.state === "rejected" ? "rejected" :
|
|
278
|
+
st.state === "blocked" ? "blocked" : null;
|
|
279
|
+
if (!verdict)
|
|
280
|
+
continue;
|
|
281
|
+
// A throwing resolve (the agent's own fn failed) must NOT kill the watcher —
|
|
282
|
+
// the entry stays parked and is retried on the next poll.
|
|
283
|
+
try {
|
|
284
|
+
const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
|
|
285
|
+
workerOpts?.onResolved?.(outcome);
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
console.warn(`[decidio] resume failed for ${p.decisionId}, will retry: ${e.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (Date.now() > deadline)
|
|
292
|
+
break;
|
|
293
|
+
await sleep(interval);
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
return { stop: () => { stopped = true; } };
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
return ctrl;
|
|
300
|
+
}
|
|
301
|
+
export { sign as _signResumeBody };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
|
|
2
|
+
export declare function canonicalize(v: unknown): string;
|
|
3
|
+
/** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
|
|
4
|
+
* and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
|
|
5
|
+
* it never goes back to Decidio). */
|
|
6
|
+
export declare function generateKeypair(): {
|
|
7
|
+
did: string;
|
|
8
|
+
privateKeyBase64: string;
|
|
9
|
+
};
|
|
10
|
+
export interface RequesterIdentity {
|
|
11
|
+
agentId: string;
|
|
12
|
+
did: string;
|
|
13
|
+
action: string;
|
|
14
|
+
amount: number;
|
|
15
|
+
scope?: string;
|
|
16
|
+
workspaceId?: string;
|
|
17
|
+
nonce: string;
|
|
18
|
+
exp: number;
|
|
19
|
+
proof: {
|
|
20
|
+
protected: string;
|
|
21
|
+
signature: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Sign the CONFIRM call (bound to {action:"confirm", scope:decisionId}) so the gate can
|
|
25
|
+
* prove the caller is the receipt's original requester before granting the wrapper tier
|
|
26
|
+
* (application_confirmed). Returns undefined when no signing key is configured (the server
|
|
27
|
+
* then caps the report at agent_asserted). Used by EVERY confirm path — inline, durable
|
|
28
|
+
* resume, and the engine adapters — so the production paths confirm with a proof too. Also
|
|
29
|
+
* binds `workspaceId` (D-B3.6) when configured, so the confirm proof is tenant-scoped too. */
|
|
30
|
+
export declare function signConfirm(decisionId: string, cfg: {
|
|
31
|
+
agentId?: string;
|
|
32
|
+
agentKey?: string;
|
|
33
|
+
agentDid?: string;
|
|
34
|
+
workspaceId?: string;
|
|
35
|
+
}): RequesterIdentity | undefined;
|
|
36
|
+
/** Sign a request-bound identity proof. The returned object is sent verbatim as the
|
|
37
|
+
* `requesterIdentity` field of /agent/authorize. */
|
|
38
|
+
export declare function signBoundProof(privateKeyBase64: string, fields: {
|
|
39
|
+
agentId: string;
|
|
40
|
+
did: string;
|
|
41
|
+
action: string;
|
|
42
|
+
amount: number;
|
|
43
|
+
scope?: string;
|
|
44
|
+
workspaceId?: string;
|
|
45
|
+
ttlMs?: number;
|
|
46
|
+
now?: number;
|
|
47
|
+
nonce?: string;
|
|
48
|
+
}): RequesterIdentity;
|
package/dist/signing.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Client-side agent identity (§13 P0-8). The agent generates its Ed25519 keypair LOCALLY
|
|
2
|
+
// (the private key never crosses the wire — `init` registers only the public did:key) and
|
|
3
|
+
// signs a REQUEST-BOUND proof on each authorize: a detached Ed25519 JWS over
|
|
4
|
+
// canonical({agentId,did,action,amount,scope,nonce,exp}). Decidio's floor verifies the
|
|
5
|
+
// signature, binds it to the exact request, burns the single-use nonce, and checks the
|
|
6
|
+
// did against the agent registry — so the gate PROVES who is calling instead of trusting a
|
|
7
|
+
// label. This canonicalize + proof shape is byte-identical to apps/api (canonical.ts +
|
|
8
|
+
// a2a/identity.ts), so a proof signed here verifies there.
|
|
9
|
+
import { generateKeyPairSync, createPrivateKey, sign as edSign, randomUUID } from "node:crypto";
|
|
10
|
+
// base58btc (Bitcoin alphabet) — for the did:key multibase 'z' prefix.
|
|
11
|
+
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
12
|
+
function b58encode(buf) {
|
|
13
|
+
const digits = [0];
|
|
14
|
+
for (const byte of buf) {
|
|
15
|
+
let carry = byte;
|
|
16
|
+
for (let i = 0; i < digits.length; i++) {
|
|
17
|
+
carry += digits[i] << 8;
|
|
18
|
+
digits[i] = carry % 58;
|
|
19
|
+
carry = (carry / 58) | 0;
|
|
20
|
+
}
|
|
21
|
+
while (carry) {
|
|
22
|
+
digits.push(carry % 58);
|
|
23
|
+
carry = (carry / 58) | 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
let str = "";
|
|
27
|
+
for (const b of buf) {
|
|
28
|
+
if (b === 0)
|
|
29
|
+
str += "1";
|
|
30
|
+
else
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
for (let i = digits.length - 1; i >= 0; i--)
|
|
34
|
+
str += B58[digits[i]];
|
|
35
|
+
return str;
|
|
36
|
+
}
|
|
37
|
+
/** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
|
|
38
|
+
export function canonicalize(v) {
|
|
39
|
+
if (v === null || typeof v !== "object")
|
|
40
|
+
return JSON.stringify(v);
|
|
41
|
+
if (Array.isArray(v))
|
|
42
|
+
return `[${v.map(canonicalize).join(",")}]`;
|
|
43
|
+
const o = v;
|
|
44
|
+
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(o[k])}`).join(",")}}`;
|
|
45
|
+
}
|
|
46
|
+
/** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
|
|
47
|
+
* and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
|
|
48
|
+
* it never goes back to Decidio). */
|
|
49
|
+
export function generateKeypair() {
|
|
50
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
51
|
+
const jwk = publicKey.export({ format: "jwk" });
|
|
52
|
+
const raw = Buffer.from(jwk.x, "base64url");
|
|
53
|
+
const did = "did:key:z" + b58encode(Buffer.concat([Buffer.from([0xed, 0x01]), raw]));
|
|
54
|
+
const privateKeyBase64 = privateKey.export({ format: "der", type: "pkcs8" }).toString("base64");
|
|
55
|
+
return { did, privateKeyBase64 };
|
|
56
|
+
}
|
|
57
|
+
/** Sign the CONFIRM call (bound to {action:"confirm", scope:decisionId}) so the gate can
|
|
58
|
+
* prove the caller is the receipt's original requester before granting the wrapper tier
|
|
59
|
+
* (application_confirmed). Returns undefined when no signing key is configured (the server
|
|
60
|
+
* then caps the report at agent_asserted). Used by EVERY confirm path — inline, durable
|
|
61
|
+
* resume, and the engine adapters — so the production paths confirm with a proof too. Also
|
|
62
|
+
* binds `workspaceId` (D-B3.6) when configured, so the confirm proof is tenant-scoped too. */
|
|
63
|
+
export function signConfirm(decisionId, cfg) {
|
|
64
|
+
if (!cfg.agentKey || !cfg.agentDid || !cfg.agentId)
|
|
65
|
+
return undefined;
|
|
66
|
+
return signBoundProof(cfg.agentKey, { agentId: cfg.agentId, did: cfg.agentDid, action: "confirm", amount: 0, scope: decisionId, workspaceId: cfg.workspaceId });
|
|
67
|
+
}
|
|
68
|
+
/** Sign a request-bound identity proof. The returned object is sent verbatim as the
|
|
69
|
+
* `requesterIdentity` field of /agent/authorize. */
|
|
70
|
+
export function signBoundProof(privateKeyBase64, fields) {
|
|
71
|
+
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
72
|
+
const nonce = fields.nonce ?? randomUUID();
|
|
73
|
+
const exp = (fields.now ?? Date.now()) + (fields.ttlMs ?? 120_000);
|
|
74
|
+
// OMIT scope/workspaceId when absent so the canonical bytes match the server + the Python SDK — a key
|
|
75
|
+
// with an undefined value would serialize as literal "undefined", which Python can't reproduce, and a
|
|
76
|
+
// legacy (no-workspace) proof must stay byte-identical. The server's bound-payload reconstruction
|
|
77
|
+
// omits them the same way (a2a/identity.ts).
|
|
78
|
+
const payloadObj = { agentId: fields.agentId, did: fields.did, action: fields.action, amount: fields.amount, ...(fields.scope !== undefined ? { scope: fields.scope } : {}), ...(fields.workspaceId !== undefined ? { workspaceId: fields.workspaceId } : {}), nonce, exp };
|
|
79
|
+
const payload = Buffer.from(canonicalize(payloadObj), "utf8").toString("base64url");
|
|
80
|
+
const protectedHdr = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: fields.did }), "utf8").toString("base64url");
|
|
81
|
+
const signature = Buffer.from(edSign(null, Buffer.from(`${protectedHdr}.${payload}`, "utf8"), key)).toString("base64url");
|
|
82
|
+
return { agentId: fields.agentId, did: fields.did, action: fields.action, amount: fields.amount, scope: fields.scope, workspaceId: fields.workspaceId, nonce, exp, proof: { protected: protectedHdr, signature } };
|
|
83
|
+
}
|
package/openapi.yaml
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# The agent-gate wire contract @decidio/sdk speaks — the machine-readable companion to the
|
|
2
|
+
# quickstart. Scope is deliberately the AGENT FLOOR only (what the floor-limited agent token can
|
|
3
|
+
# reach) plus the outbound resume webhook Decidio sends; workspace administration (register,
|
|
4
|
+
# token mint, approvals) is a human/session surface and stays out of this file.
|
|
5
|
+
openapi: 3.1.0
|
|
6
|
+
info:
|
|
7
|
+
title: Decidio Agent Gate
|
|
8
|
+
version: 0.1.0
|
|
9
|
+
description: >
|
|
10
|
+
Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
|
|
11
|
+
receive the signed resume webhook when a routed action is decided. Every outcome seals a
|
|
12
|
+
verifiable Authority Receipt — verify offline with @decidio/verify (npm) or
|
|
13
|
+
`python -m decidio.verify` (pip install decidio).
|
|
14
|
+
license:
|
|
15
|
+
name: Apache-2.0
|
|
16
|
+
servers:
|
|
17
|
+
- url: https://decidio-api.onrender.com
|
|
18
|
+
description: Hosted sandbox (synthetic tester lab)
|
|
19
|
+
- url: http://localhost:4000
|
|
20
|
+
description: Local development
|
|
21
|
+
security:
|
|
22
|
+
- agentToken: []
|
|
23
|
+
components:
|
|
24
|
+
securitySchemes:
|
|
25
|
+
agentToken:
|
|
26
|
+
type: http
|
|
27
|
+
scheme: bearer
|
|
28
|
+
description: >
|
|
29
|
+
The FLOOR-LIMITED agent token minted by `npx @decidio/sdk init` (or Connect → Agents).
|
|
30
|
+
It authenticates only these /agent/* paths and /a2a — every other API path refuses it.
|
|
31
|
+
schemas:
|
|
32
|
+
RequesterIdentity:
|
|
33
|
+
type: object
|
|
34
|
+
description: >
|
|
35
|
+
Request-bound Ed25519 identity proof (detached JWS over the RFC 8785-canonicalized
|
|
36
|
+
payload; 120s TTL, single-use nonce). Registered agents must sign to be eligible for
|
|
37
|
+
policy auto-approve; unsigned requests still work but always route to a human.
|
|
38
|
+
required: [agentId, did, action, amount, nonce, exp, proof]
|
|
39
|
+
properties:
|
|
40
|
+
agentId: { type: string }
|
|
41
|
+
did: { type: string, description: "did:key of the agent's LOCALLY generated keypair" }
|
|
42
|
+
action: { type: string }
|
|
43
|
+
amount: { type: number }
|
|
44
|
+
scope: { type: string }
|
|
45
|
+
workspaceId: { type: string }
|
|
46
|
+
nonce: { type: string }
|
|
47
|
+
exp: { type: integer, description: unix ms expiry }
|
|
48
|
+
proof:
|
|
49
|
+
type: object
|
|
50
|
+
required: [protected, signature]
|
|
51
|
+
properties:
|
|
52
|
+
protected: { type: string, description: base64url JWS protected header (EdDSA, kid=did) }
|
|
53
|
+
signature: { type: string, description: base64url detached signature }
|
|
54
|
+
AuthorizeRequest:
|
|
55
|
+
type: object
|
|
56
|
+
required: [requester, action, amount]
|
|
57
|
+
properties:
|
|
58
|
+
requester: { type: string, description: the registered agentId }
|
|
59
|
+
action: { type: string }
|
|
60
|
+
amount: { type: number }
|
|
61
|
+
scope: { type: string }
|
|
62
|
+
sourceSystem: { type: string }
|
|
63
|
+
context: { type: object, additionalProperties: true }
|
|
64
|
+
requesterIdentity: { $ref: "#/components/schemas/RequesterIdentity" }
|
|
65
|
+
resumeUrl:
|
|
66
|
+
type: string
|
|
67
|
+
format: uri
|
|
68
|
+
description: where Decidio POSTs the signed verdict when a routed decision resolves
|
|
69
|
+
AuthorizeResponse:
|
|
70
|
+
type: object
|
|
71
|
+
required: [decision, decisionId, receiptId]
|
|
72
|
+
properties:
|
|
73
|
+
decision:
|
|
74
|
+
type: string
|
|
75
|
+
enum: [proceed, route, block]
|
|
76
|
+
description: >
|
|
77
|
+
Treat any OTHER value as block — the SDKs allow-list these three so an older client
|
|
78
|
+
fails closed against a newer server.
|
|
79
|
+
decisionId: { type: string }
|
|
80
|
+
receiptId: { type: string }
|
|
81
|
+
reason: { type: string }
|
|
82
|
+
StatusResponse:
|
|
83
|
+
type: object
|
|
84
|
+
required: [state]
|
|
85
|
+
properties:
|
|
86
|
+
state:
|
|
87
|
+
type: string
|
|
88
|
+
enum: [pending, approved, auto_approved, rejected, blocked]
|
|
89
|
+
reason: { type: string }
|
|
90
|
+
ConfirmRequest:
|
|
91
|
+
type: object
|
|
92
|
+
required: [decisionId, capturedResponse, source]
|
|
93
|
+
properties:
|
|
94
|
+
decisionId: { type: string }
|
|
95
|
+
capturedResponse:
|
|
96
|
+
description: the REAL downstream response the wrapper captured (server minimizes + tokenizes before sealing)
|
|
97
|
+
source:
|
|
98
|
+
type: string
|
|
99
|
+
enum: [wrapper, agent]
|
|
100
|
+
description: wrapper = SDK-captured (eligible for application_confirmed); agent = self-report (capped at agent_asserted)
|
|
101
|
+
requesterIdentity:
|
|
102
|
+
$ref: "#/components/schemas/RequesterIdentity"
|
|
103
|
+
description: confirm proof is bound to {action:"confirm", scope:decisionId}
|
|
104
|
+
ResumeWebhook:
|
|
105
|
+
type: object
|
|
106
|
+
description: >
|
|
107
|
+
OUTBOUND — Decidio → your resumeUrl when a routed decision is decided. The raw body is
|
|
108
|
+
HMAC-signed; verify `x-decidio-signature: sha256=<hex>` with your DECIDIO_WEBHOOK_SECRET
|
|
109
|
+
BEFORE acting (the SDK resume controllers fail closed). Re-execution is single-use.
|
|
110
|
+
required: [decisionId, verdict]
|
|
111
|
+
properties:
|
|
112
|
+
decisionId: { type: string }
|
|
113
|
+
verdict: { type: string, enum: [approved, rejected] }
|
|
114
|
+
# NOTE — deliberately NO `reason` field: the human's justification is never delivered to an
|
|
115
|
+
# agent-controlled URL (that would egress cleartext rationale). Agents read the tokenized
|
|
116
|
+
# reason via GET /agent/status/{decisionId} instead. Do not "fix" the server to match a
|
|
117
|
+
# richer webhook shape.
|
|
118
|
+
paths:
|
|
119
|
+
/agent/authorize:
|
|
120
|
+
post:
|
|
121
|
+
summary: Ask the gate to authorize one agent action
|
|
122
|
+
requestBody:
|
|
123
|
+
required: true
|
|
124
|
+
content:
|
|
125
|
+
application/json:
|
|
126
|
+
schema: { $ref: "#/components/schemas/AuthorizeRequest" }
|
|
127
|
+
responses:
|
|
128
|
+
"200":
|
|
129
|
+
description: verdict
|
|
130
|
+
content:
|
|
131
|
+
application/json:
|
|
132
|
+
schema: { $ref: "#/components/schemas/AuthorizeResponse" }
|
|
133
|
+
"401": { description: missing/invalid agent token }
|
|
134
|
+
/agent/status/{decisionId}:
|
|
135
|
+
get:
|
|
136
|
+
summary: Poll a routed decision (blocking mode / durable worker)
|
|
137
|
+
parameters:
|
|
138
|
+
- name: decisionId
|
|
139
|
+
in: path
|
|
140
|
+
required: true
|
|
141
|
+
schema: { type: string }
|
|
142
|
+
responses:
|
|
143
|
+
"200":
|
|
144
|
+
description: current state
|
|
145
|
+
content:
|
|
146
|
+
application/json:
|
|
147
|
+
schema: { $ref: "#/components/schemas/StatusResponse" }
|
|
148
|
+
"401": { description: missing/invalid agent token }
|
|
149
|
+
"404": { description: unknown decision }
|
|
150
|
+
/agent/confirm:
|
|
151
|
+
post:
|
|
152
|
+
summary: Report the captured execution outcome (seals the receipt's evidence tier)
|
|
153
|
+
requestBody:
|
|
154
|
+
required: true
|
|
155
|
+
content:
|
|
156
|
+
application/json:
|
|
157
|
+
schema: { $ref: "#/components/schemas/ConfirmRequest" }
|
|
158
|
+
responses:
|
|
159
|
+
"200": { description: recorded }
|
|
160
|
+
"401": { description: missing/invalid agent token }
|
|
161
|
+
x-webhooks:
|
|
162
|
+
resume:
|
|
163
|
+
post:
|
|
164
|
+
summary: Signed verdict delivery (Decidio → agent resumeUrl)
|
|
165
|
+
requestBody:
|
|
166
|
+
content:
|
|
167
|
+
application/json:
|
|
168
|
+
schema: { $ref: "#/components/schemas/ResumeWebhook" }
|
|
169
|
+
responses:
|
|
170
|
+
"200": { description: agent accepted and (single-use) re-executed }
|
|
171
|
+
"401": { description: signature missing/invalid — the controller fails closed }
|