@decidio/sdk 0.1.9 → 0.1.10
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/README.md +9 -4
- package/dist/adapters/_gate.js +9 -20
- package/dist/adapters/langgraph.js +0 -14
- package/dist/adapters/openai.js +0 -4
- package/dist/adapters/temporal.js +1 -17
- package/dist/cli.js +32 -254
- package/dist/guard.d.ts +9 -7
- package/dist/guard.js +12 -26
- package/dist/index.d.ts +7 -9
- package/dist/index.js +17 -98
- package/dist/inngest.js +0 -26
- package/dist/resume.d.ts +11 -12
- package/dist/resume.js +31 -118
- package/dist/signing.d.ts +16 -6
- package/dist/signing.js +23 -40
- package/openapi.yaml +1 -1
- package/package.json +3 -9
package/dist/resume.js
CHANGED
|
@@ -1,27 +1,9 @@
|
|
|
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
1
|
import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
|
|
21
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
22
3
|
import { join } from "node:path";
|
|
23
|
-
import {
|
|
24
|
-
|
|
4
|
+
import { userInfo } from "node:os";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
|
|
25
7
|
const argsBindingBytes = (p) => Buffer.from(canonicalize({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
|
|
26
8
|
function signArgs(p, agentKey, webhookSecret) {
|
|
27
9
|
const bytes = argsBindingBytes(p);
|
|
@@ -30,7 +12,7 @@ function signArgs(p, agentKey, webhookSecret) {
|
|
|
30
12
|
const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
|
|
31
13
|
return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
|
|
32
14
|
}
|
|
33
|
-
catch {
|
|
15
|
+
catch { }
|
|
34
16
|
}
|
|
35
17
|
if (webhookSecret)
|
|
36
18
|
return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
|
|
@@ -68,16 +50,23 @@ export class MemoryPendingStore {
|
|
|
68
50
|
return false; this.claimed.add(id); return true; }
|
|
69
51
|
async release(id) { this.claimed.delete(id); }
|
|
70
52
|
}
|
|
71
|
-
/** File-backed store so a COLD process (the original requester long gone) can still
|
|
72
|
-
* resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
|
|
73
|
-
* renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
|
|
74
|
-
* sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
|
|
75
|
-
* TTL recovery — tracked in the backlog.) */
|
|
76
53
|
export class FilePendingStore {
|
|
77
54
|
dir;
|
|
78
55
|
constructor(dir) {
|
|
79
56
|
this.dir = dir;
|
|
80
57
|
mkdirSync(dir, { recursive: true });
|
|
58
|
+
if (process.platform === "win32") {
|
|
59
|
+
try {
|
|
60
|
+
const user = (() => { try {
|
|
61
|
+
return userInfo().username;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return process.env.USERNAME ?? process.env.USER ?? "";
|
|
65
|
+
} })();
|
|
66
|
+
execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { stdio: "ignore" });
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
}
|
|
81
70
|
}
|
|
82
71
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
83
72
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
@@ -95,12 +84,8 @@ export class FilePendingStore {
|
|
|
95
84
|
async list() {
|
|
96
85
|
if (!existsSync(this.dir))
|
|
97
86
|
return [];
|
|
98
|
-
// ONE BAD FILE MUST NOT STALL THE QUEUE: an unparseable entry (partial write, disk corruption,
|
|
99
|
-
// a human editing it) used to throw out of list() and take every other parked action down with
|
|
100
|
-
// it — a worker that can never poll is a worker that never resumes anything. Skip it loudly
|
|
101
|
-
// and keep the rest moving; the bad file stays on disk for inspection.
|
|
102
87
|
const out = [];
|
|
103
|
-
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
|
|
88
|
+
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
|
|
104
89
|
try {
|
|
105
90
|
out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
|
|
106
91
|
}
|
|
@@ -114,7 +99,7 @@ export class FilePendingStore {
|
|
|
114
99
|
try {
|
|
115
100
|
renameSync(this.path(id), this.claimedPath(id));
|
|
116
101
|
return true;
|
|
117
|
-
}
|
|
102
|
+
}
|
|
118
103
|
catch {
|
|
119
104
|
return false;
|
|
120
105
|
}
|
|
@@ -123,13 +108,9 @@ export class FilePendingStore {
|
|
|
123
108
|
try {
|
|
124
109
|
renameSync(this.claimedPath(id), this.path(id));
|
|
125
110
|
}
|
|
126
|
-
catch {
|
|
111
|
+
catch { }
|
|
127
112
|
}
|
|
128
113
|
}
|
|
129
|
-
/** Thrown by a `withApproval`-wrapped call when the action is routed and configured
|
|
130
|
-
* for async resume: the action is now SUSPENDED (parked in the store), not failed.
|
|
131
|
-
* The caller can stop here — the process may even exit; the resume transport will
|
|
132
|
-
* complete it later. */
|
|
133
114
|
export class DecidioSuspendedError extends Error {
|
|
134
115
|
decisionId;
|
|
135
116
|
action;
|
|
@@ -144,8 +125,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
144
125
|
function sign(secret, body) {
|
|
145
126
|
return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
|
|
146
127
|
}
|
|
147
|
-
/** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
|
|
148
|
-
* Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
|
|
149
128
|
export function verifyResumeSignature(secret, body, header) {
|
|
150
129
|
if (!header)
|
|
151
130
|
return false;
|
|
@@ -153,11 +132,6 @@ export function verifyResumeSignature(secret, body, header) {
|
|
|
153
132
|
const a = Buffer.from(expected), b = Buffer.from(header);
|
|
154
133
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
155
134
|
}
|
|
156
|
-
/** The ONE confirm-body rule, used by ALL THREE TS confirm doors (resume, inline withApproval,
|
|
157
|
-
* adapter gate — guard equivalent-path catch: it lived only on the resume door). Transport
|
|
158
|
-
* success is not confirmation (prodsec F-03): a 2xx whose body carries no recorded tier is a
|
|
159
|
-
* FAILED confirm; a SIGNED confirm coming back below application_confirmed is disclosed, never
|
|
160
|
-
* silent. Returns the recorded tier or throws. */
|
|
161
135
|
export function assertConfirmRecorded(body, decisionId, signed) {
|
|
162
136
|
if (!body || body.ok !== true || typeof body.confirmation !== "string") {
|
|
163
137
|
throw new Error(`confirm answered 2xx but reported no evidence tier — treating the confirmation as NOT recorded`);
|
|
@@ -167,14 +141,10 @@ export function assertConfirmRecorded(body, decisionId, signed) {
|
|
|
167
141
|
}
|
|
168
142
|
return body.confirmation;
|
|
169
143
|
}
|
|
170
|
-
/** Resume-door confirm: POST + the shared body rule above. */
|
|
171
144
|
async function confirm(cfg, decisionId, capturedResponse) {
|
|
172
145
|
const f = cfg.fetchImpl ?? fetch;
|
|
173
|
-
|
|
174
|
-
// {action:"confirm", scope:decisionId}); without a key the server caps it at agent_asserted.
|
|
146
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
175
147
|
const requesterIdentity = signConfirm(decisionId, cfg);
|
|
176
|
-
// Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch): the durable
|
|
177
|
-
// path is what production agents run, so it must disclose an agent_asserted cap too.
|
|
178
148
|
if (!requesterIdentity)
|
|
179
149
|
console.warn(`[decidio] execution evidence for ${decisionId} recorded as agent_asserted — no signing key configured, so the report can't be proven to come from this agent. Set DECIDIO_AGENT_KEY/DID to earn application_confirmed.`);
|
|
180
150
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
@@ -189,6 +159,7 @@ async function confirm(cfg, decisionId, capturedResponse) {
|
|
|
189
159
|
}
|
|
190
160
|
async function statusOf(cfg, decisionId) {
|
|
191
161
|
const f = cfg.fetchImpl ?? fetch;
|
|
162
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
192
163
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
|
|
193
164
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
194
165
|
});
|
|
@@ -196,24 +167,10 @@ async function statusOf(cfg, decisionId) {
|
|
|
196
167
|
throw new Error(`status ${r.status}`);
|
|
197
168
|
return r.json();
|
|
198
169
|
}
|
|
199
|
-
/**
|
|
200
|
-
* Build the resume controller. Spread it into your GuardConfig as `resume`, then
|
|
201
|
-
* either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
|
|
202
|
-
*
|
|
203
|
-
* const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
|
|
204
|
-
* const guard = { apiUrl, agentId, apiToken, resume };
|
|
205
|
-
* const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
|
|
206
|
-
* // production: http.createServer(resume.webhookHandler()).listen(4100)
|
|
207
|
-
* // or CLI: resume.worker()
|
|
208
|
-
*/
|
|
209
170
|
export function createDecidioResume(opts) {
|
|
210
171
|
const registry = new Map();
|
|
211
|
-
const inFlight = new Set();
|
|
172
|
+
const inFlight = new Set();
|
|
212
173
|
const secret = opts.webhookSecret ?? opts.apiToken ?? "";
|
|
213
|
-
// CONTROLLER-scoped, not resolve()-scoped (guard catch: declared inside resolve, this Set was
|
|
214
|
-
// reborn empty every poll — the refusal printed 3/3 times, each ending with a false "prints
|
|
215
|
-
// once"). Refusals repeat on every cycle while the file sits parked; the MESSAGE must not.
|
|
216
|
-
// The opt-out of webhook authenticity must never be silent (prodsec F-07).
|
|
217
174
|
if (opts.allowUnsigned)
|
|
218
175
|
console.warn("[decidio] WARNING: allowUnsigned is ON — this controller will accept UNSIGNED resume webhooks. Anyone who can reach the endpoint can signal work (parked-payload integrity still gates execution). Use this only behind a trusted network boundary, never on an internet-reachable endpoint.");
|
|
219
176
|
const refusalWarned = new Set();
|
|
@@ -225,29 +182,25 @@ export function createDecidioResume(opts) {
|
|
|
225
182
|
};
|
|
226
183
|
const ctrl = {
|
|
227
184
|
...opts,
|
|
228
|
-
// Default the signing identity from env so a COLD resume process (a fresh worker that has
|
|
229
|
-
// the agent's env but wasn't passed config) still signs the confirm and earns the wrapper tier.
|
|
230
185
|
agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
|
|
231
186
|
agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
|
|
232
187
|
agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
|
|
233
|
-
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
188
|
+
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
234
189
|
webhookSecret: secret,
|
|
235
190
|
store: opts.store,
|
|
236
191
|
register(action, fn) {
|
|
237
|
-
// Silent last-writer-wins is the wrong default for an EXECUTION registry: two wrappers
|
|
238
|
-
// sharing an action name would let a resumed decision replay its args into the other
|
|
239
|
-
// function. Say so loudly — the parked args and the fn must be the pair that was routed.
|
|
240
192
|
const prev = registry.get(action);
|
|
241
193
|
if (prev && prev !== fn)
|
|
242
194
|
console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT function — the latest one wins, and a resumed decision would run IT. Give each protected function its own action name.`);
|
|
243
195
|
registry.set(action, fn);
|
|
244
196
|
},
|
|
245
197
|
async park(p) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
198
|
+
try {
|
|
199
|
+
assertJsonSafe(p.args, "args");
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} NOT parked and NOT executable — but decision ${p.decisionId} may already be routed; reject it in your queue: npx @decidio/sdk approvals reject ${p.decisionId}`, p.decisionId);
|
|
203
|
+
}
|
|
251
204
|
const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
|
|
252
205
|
if (!argsSig)
|
|
253
206
|
console.warn(`[decidio] parking ${p.decisionId} UNBOUND — no agent key or webhook secret to sign the payload with, so a modified pending file could not be detected. Run \`npx @decidio/sdk init\` to configure keys.`);
|
|
@@ -258,7 +211,7 @@ export function createDecidioResume(opts) {
|
|
|
258
211
|
opts.onResolved?.(o); return o; };
|
|
259
212
|
const pending = await ctrl.store.get(decisionId);
|
|
260
213
|
if (!pending)
|
|
261
|
-
return { status: "unknown", decisionId };
|
|
214
|
+
return { status: "unknown", decisionId };
|
|
262
215
|
if (verdict === "rejected") {
|
|
263
216
|
await ctrl.store.delete(decisionId);
|
|
264
217
|
return finish({ status: "rejected", decisionId, action: pending.action, reason });
|
|
@@ -267,24 +220,12 @@ export function createDecidioResume(opts) {
|
|
|
267
220
|
await ctrl.store.delete(decisionId);
|
|
268
221
|
return finish({ status: "blocked", decisionId, action: pending.action, reason });
|
|
269
222
|
}
|
|
270
|
-
// Fail CLOSED on any unexpected verdict — never execute on a garbage/unknown value.
|
|
271
223
|
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
272
224
|
return { status: "unknown", decisionId };
|
|
273
|
-
// INTEGRITY BEFORE EXECUTION (round-6 acceptance catch: a locally edited pending file
|
|
274
|
-
// executed different arguments from those routed for approval).
|
|
275
|
-
// SLOT ASSERTION FIRST (guard catch): the store keys on the FILENAME while the signature
|
|
276
|
-
// covers the CONTENT's decisionId — without this line, copying validly-signed entry A into
|
|
277
|
-
// slot B executes A's args when B is approved. No forgery needed, so it must be checked
|
|
278
|
-
// even before signature verification.
|
|
279
225
|
if (pending.decisionId !== decisionId) {
|
|
280
226
|
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked entry in this slot belongs to a DIFFERENT decision (${pending.decisionId}) — the pending file was moved or swapped. Delete it and re-run the action.`);
|
|
281
227
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
|
|
282
228
|
}
|
|
283
|
-
// STRICT binding is keyed to the agent's ED25519 signing key (what `init` writes) — an
|
|
284
|
-
// init'd agent always signs via park(), so an unbound entry there is tampering (incl. a
|
|
285
|
-
// stripped signature). A bare store.put user with no agent key keeps the documented
|
|
286
|
-
// low-level behavior (execute+warn); the apiToken-derived HMAC secret does NOT trigger
|
|
287
|
-
// strict mode (it lives in .env next to the pending dir — not a local-tamper defense).
|
|
288
229
|
const strictBinding = !!ctrl.agentKey;
|
|
289
230
|
const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
|
|
290
231
|
if (integrity === "mismatch") {
|
|
@@ -296,9 +237,6 @@ export function createDecidioResume(opts) {
|
|
|
296
237
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
|
|
297
238
|
}
|
|
298
239
|
if (integrity === "unbound" && strictBinding) {
|
|
299
|
-
// A controller WITH key material would have signed at park time, so a MISSING signature is
|
|
300
|
-
// itself tampering — an attacker stripping argsSig to reach the legacy path. Refuse. Only a
|
|
301
|
-
// keyless controller (genuine legacy / no-key deploy) may execute an unbound entry.
|
|
302
240
|
refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload has NO integrity signature, but this agent has keys and would have signed it — a missing signature means the file was tampered with. Delete it and re-run the action.`);
|
|
303
241
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
|
|
304
242
|
}
|
|
@@ -306,8 +244,6 @@ export function createDecidioResume(opts) {
|
|
|
306
244
|
console.warn(`[decidio] executing ${decisionId} from an UNBOUND parked entry (this agent has no key material to bind or verify payloads) — its arguments cannot be integrity-checked. Run \`npx @decidio/sdk init\` to enable binding.`);
|
|
307
245
|
const fn = registry.get(pending.action);
|
|
308
246
|
if (!fn) {
|
|
309
|
-
// Actionable, because this is the failure a restarted worker actually hits: the registry
|
|
310
|
-
// is per-process, and a cold process never ran the original call that would have filled it.
|
|
311
247
|
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
312
248
|
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
313
249
|
console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
|
|
@@ -316,19 +252,11 @@ export function createDecidioResume(opts) {
|
|
|
316
252
|
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
317
253
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" in this process — declare { action } on protect(), or call guard.register("${pending.action}", fn) at startup` });
|
|
318
254
|
}
|
|
319
|
-
// Atomically CLAIM before executing — the cross-process single-use guarantee. If
|
|
320
|
-
// the store has no claim primitive, fall back to an in-process guard (same-process).
|
|
321
255
|
const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
|
|
322
256
|
if (!claimed)
|
|
323
|
-
return { status: "unknown", decisionId };
|
|
257
|
+
return { status: "unknown", decisionId };
|
|
324
258
|
try {
|
|
325
|
-
// Approved: the AGENT performs its own action FIRST. If it throws, RELEASE the claim so
|
|
326
|
-
// the entry stays parked + retryable — an approved action is never lost.
|
|
327
259
|
const result = await fn(...pending.args);
|
|
328
|
-
// CONFIRM BEFORE DELETE. Deleting first meant a failed confirm lost the only local record
|
|
329
|
-
// that this action had executed — the evidence could never be re-sent. The entry stays
|
|
330
|
-
// CLAIMED throughout (claim, not presence, is what prevents double-execution), so keeping
|
|
331
|
-
// it a moment longer cannot cause a second run.
|
|
332
260
|
let confirmed = true;
|
|
333
261
|
let tier;
|
|
334
262
|
try {
|
|
@@ -338,12 +266,6 @@ export function createDecidioResume(opts) {
|
|
|
338
266
|
confirmed = false;
|
|
339
267
|
console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
|
|
340
268
|
}
|
|
341
|
-
// RETAIN-vs-DELETE on a failed confirm. Keeping the entry preserves local evidence that
|
|
342
|
-
// this action executed — but ONLY a store with claim() can guarantee a retained entry
|
|
343
|
-
// never runs again (its claim refuses it; the in-process fallback dies with the process).
|
|
344
|
-
// For a claim-LESS store the delete IS the single-use guarantee, so it must still happen:
|
|
345
|
-
// an undelivered confirmation is an evidence gap, a second execution is a double write,
|
|
346
|
-
// and we never trade the second away for the first.
|
|
347
269
|
const retainedIsSafe = typeof ctrl.store.claim === "function";
|
|
348
270
|
if (confirmed || !retainedIsSafe)
|
|
349
271
|
await ctrl.store.delete(decisionId);
|
|
@@ -374,9 +296,6 @@ export function createDecidioResume(opts) {
|
|
|
374
296
|
req.on("data", (c) => (raw += c));
|
|
375
297
|
req.on("end", async () => {
|
|
376
298
|
try {
|
|
377
|
-
// Fail CLOSED when no secret is configured: an agent that forgot to set a
|
|
378
|
-
// webhookSecret must NOT be drivable by forged callbacks. Opt out only with
|
|
379
|
-
// allowUnsigned (trusted network boundary).
|
|
380
299
|
if (!secret) {
|
|
381
300
|
if (!opts.allowUnsigned) {
|
|
382
301
|
res.statusCode = 401;
|
|
@@ -443,14 +362,8 @@ export function createDecidioResume(opts) {
|
|
|
443
362
|
st.state === "blocked" ? "blocked" : null;
|
|
444
363
|
if (!verdict)
|
|
445
364
|
continue;
|
|
446
|
-
// A throwing resolve (the agent's own fn failed) must NOT kill the watcher —
|
|
447
|
-
// the entry stays parked and is retried on the next poll.
|
|
448
365
|
try {
|
|
449
366
|
const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
|
|
450
|
-
// ONE line per lifecycle event, on stderr (round-3 blind catch: the
|
|
451
|
-
// production-recommended worker executed, restart-resumed and discarded actions in
|
|
452
|
-
// total silence — the only evidence was a file vanishing). onResolved remains the
|
|
453
|
-
// programmatic hook; this is the human-visible floor.
|
|
454
367
|
if (outcome.status === "executed" && outcome.confirmed)
|
|
455
368
|
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, evidence recorded as ${outcome.evidenceTier ?? "confirmed"}.`);
|
|
456
369
|
else if (outcome.status === "executed")
|
package/dist/signing.d.ts
CHANGED
|
@@ -46,11 +46,21 @@ export declare function signBoundProof(privateKeyBase64: string, fields: {
|
|
|
46
46
|
now?: number;
|
|
47
47
|
nonce?: string;
|
|
48
48
|
}): RequesterIdentity;
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* returns nothing. The supported data model is plain JSON: null, boolean, string, finite
|
|
49
|
+
/** Refuse values that do not survive `JSON.stringify → JSON.parse` with their meaning intact,
|
|
50
|
+
* BEFORE anything is routed, signed, or parked. `NaN` canonicalizes to `null` and a `Map` to
|
|
51
|
+
* `{}` — an integrity signature over those bytes would verify while the function executed
|
|
52
|
+
* arguments that meant something else, so the gate refuses them instead. Throws naming the
|
|
53
|
+
* offending path. The supported data model is plain JSON: null, boolean, string, finite
|
|
55
54
|
* number, plain array, plain object. */
|
|
55
|
+
/** Typed refusal for values that cannot survive JSON with their meaning intact. `decisionId`
|
|
56
|
+
* is set only when a decision already exists (defense-in-depth refusals after routing). */
|
|
57
|
+
export declare class DecidioUnsafeArgumentsError extends Error {
|
|
58
|
+
decisionId?: string | undefined;
|
|
59
|
+
constructor(message: string, decisionId?: string | undefined);
|
|
60
|
+
}
|
|
56
61
|
export declare function assertJsonSafe(value: unknown, path?: string): void;
|
|
62
|
+
/** The wire rule: a credentialed request must never ride plaintext http to a remote host — the
|
|
63
|
+
* rule is about the WIRE, not which credential (the interactive sign-in enforced this for
|
|
64
|
+
* session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
|
|
65
|
+
* allowed for local dev; https always allowed. Throws before anything is sent. */
|
|
66
|
+
export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
|
package/dist/signing.js
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
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
1
|
import { generateKeyPairSync, createPrivateKey, sign as edSign, randomUUID } from "node:crypto";
|
|
10
|
-
// base58btc (Bitcoin alphabet) — for the did:key multibase 'z' prefix.
|
|
11
2
|
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
12
3
|
function b58encode(buf) {
|
|
13
4
|
const digits = [0];
|
|
@@ -34,7 +25,6 @@ function b58encode(buf) {
|
|
|
34
25
|
str += B58[digits[i]];
|
|
35
26
|
return str;
|
|
36
27
|
}
|
|
37
|
-
/** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
|
|
38
28
|
export function canonicalize(v) {
|
|
39
29
|
if (v === null || typeof v !== "object")
|
|
40
30
|
return JSON.stringify(v);
|
|
@@ -43,9 +33,6 @@ export function canonicalize(v) {
|
|
|
43
33
|
const o = v;
|
|
44
34
|
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(o[k])}`).join(",")}}`;
|
|
45
35
|
}
|
|
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
36
|
export function generateKeypair() {
|
|
50
37
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
51
38
|
const jwk = publicKey.export({ format: "jwk" });
|
|
@@ -54,40 +41,29 @@ export function generateKeypair() {
|
|
|
54
41
|
const privateKeyBase64 = privateKey.export({ format: "der", type: "pkcs8" }).toString("base64");
|
|
55
42
|
return { did, privateKeyBase64 };
|
|
56
43
|
}
|
|
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
44
|
export function signConfirm(decisionId, cfg) {
|
|
64
45
|
if (!cfg.agentKey || !cfg.agentDid || !cfg.agentId)
|
|
65
46
|
return undefined;
|
|
66
47
|
return signBoundProof(cfg.agentKey, { agentId: cfg.agentId, did: cfg.agentDid, action: "confirm", amount: 0, scope: decisionId, workspaceId: cfg.workspaceId });
|
|
67
48
|
}
|
|
68
|
-
/** Sign a request-bound identity proof. The returned object is sent verbatim as the
|
|
69
|
-
* `requesterIdentity` field of /agent/authorize. */
|
|
70
49
|
export function signBoundProof(privateKeyBase64, fields) {
|
|
71
50
|
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
72
51
|
const nonce = fields.nonce ?? randomUUID();
|
|
73
52
|
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
53
|
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
54
|
const payload = Buffer.from(canonicalize(payloadObj), "utf8").toString("base64url");
|
|
80
55
|
const protectedHdr = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: fields.did }), "utf8").toString("base64url");
|
|
81
56
|
const signature = Buffer.from(edSign(null, Buffer.from(`${protectedHdr}.${payload}`, "utf8"), key)).toString("base64url");
|
|
82
57
|
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
58
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
59
|
+
export class DecidioUnsafeArgumentsError extends Error {
|
|
60
|
+
decisionId;
|
|
61
|
+
constructor(message, decisionId) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.decisionId = decisionId;
|
|
64
|
+
this.name = "DecidioUnsafeArgumentsError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
91
67
|
export function assertJsonSafe(value, path = "args") {
|
|
92
68
|
const seen = new Set();
|
|
93
69
|
const walk = (v, p) => {
|
|
@@ -95,18 +71,18 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
95
71
|
return;
|
|
96
72
|
if (typeof v === "number") {
|
|
97
73
|
if (!Number.isFinite(v))
|
|
98
|
-
throw new
|
|
74
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is ${String(v)} — a non-finite number does not survive JSON (it becomes null), so what a human approves would not be what executes. pass a finite number or a string.`);
|
|
99
75
|
return;
|
|
100
76
|
}
|
|
101
77
|
if (typeof v === "undefined")
|
|
102
|
-
throw new
|
|
78
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is undefined — JSON drops or nullifies it, so what a human approves would not be what executes. use null explicitly or omit the field.`);
|
|
103
79
|
if (typeof v === "bigint")
|
|
104
|
-
throw new
|
|
80
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is a BigInt — JSON cannot represent it. pass it as a string.`);
|
|
105
81
|
if (typeof v === "function" || typeof v === "symbol")
|
|
106
|
-
throw new
|
|
82
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is a ${typeof v} — it cannot be serialized for approval.`);
|
|
107
83
|
if (typeof v === "object") {
|
|
108
84
|
if (seen.has(v))
|
|
109
|
-
throw new
|
|
85
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is part of a circular structure — it cannot be serialized for approval.`);
|
|
110
86
|
seen.add(v);
|
|
111
87
|
if (Array.isArray(v)) {
|
|
112
88
|
v.forEach((x, i) => walk(x, `${p}[${i}]`));
|
|
@@ -116,16 +92,23 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
116
92
|
const proto = Object.getPrototypeOf(v);
|
|
117
93
|
if (proto !== Object.prototype && proto !== null) {
|
|
118
94
|
const name = v?.constructor?.name ?? "a class instance";
|
|
119
|
-
throw new
|
|
95
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is ${name === "Object" ? "an exotic object" : `a ${name}`} — only plain JSON objects survive serialization with their meaning intact (a Map, for example, becomes {}); convert it to plain JSON first.`);
|
|
120
96
|
}
|
|
121
97
|
if (typeof v.toJSON === "function")
|
|
122
|
-
throw new
|
|
98
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} defines toJSON() — its serialized form would differ from the value the signature covers. pass the plain serialized form instead.`);
|
|
123
99
|
for (const [k, x] of Object.entries(v))
|
|
124
100
|
walk(x, `${p}.${k}`);
|
|
125
101
|
seen.delete(v);
|
|
126
102
|
return;
|
|
127
103
|
}
|
|
128
|
-
throw new
|
|
104
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} has unsupported type ${typeof v}.`);
|
|
129
105
|
};
|
|
130
106
|
walk(value, path);
|
|
131
107
|
}
|
|
108
|
+
export function assertSafeTransport(url, hasCredential) {
|
|
109
|
+
if (!hasCredential)
|
|
110
|
+
return;
|
|
111
|
+
if (/^http:\/\//i.test(url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(url)) {
|
|
112
|
+
throw new Error(`[decidio] refusing to send credentials over plaintext http to ${url.replace(/^(https?:\/\/[^/]+).*$/i, "$1")} — use https, or localhost for local dev. Nothing was sent.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
package/openapi.yaml
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
openapi: 3.1.0
|
|
6
6
|
info:
|
|
7
7
|
title: Decidio Agent Gate
|
|
8
|
-
version: 0.1.
|
|
8
|
+
version: 0.1.10
|
|
9
9
|
description: >
|
|
10
10
|
Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
|
|
11
11
|
receive the signed resume webhook when a routed action is decided. Every outcome seals a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -77,20 +77,14 @@
|
|
|
77
77
|
},
|
|
78
78
|
"scripts": {
|
|
79
79
|
"typecheck": "tsc --noEmit",
|
|
80
|
-
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
80
|
+
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
|
|
81
81
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@decidio/verify": "0.1.
|
|
84
|
+
"@decidio/verify": "0.1.9"
|
|
85
85
|
},
|
|
86
86
|
"author": "OmniTwin Technologies Inc.",
|
|
87
87
|
"bugs": {
|
|
88
88
|
"url": "https://decidioai.com/support/"
|
|
89
|
-
},
|
|
90
|
-
"peerDependencies": {
|
|
91
|
-
"inngest": ">=3 <4",
|
|
92
|
-
"@langchain/langgraph": ">=0.2 <2",
|
|
93
|
-
"@temporalio/workflow": ">=1 <2",
|
|
94
|
-
"@openai/agents": ">=0.0.1 <2"
|
|
95
89
|
}
|
|
96
90
|
}
|