@decidio/sdk 0.1.9 → 0.1.11
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 +134 -29
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +69 -26
- package/dist/adapters/langgraph.js +2 -15
- package/dist/adapters/openai.js +2 -5
- package/dist/adapters/temporal.js +18 -20
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +104 -0
- package/dist/cli.js +146 -272
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +41 -0
- package/dist/evidence.js +57 -0
- package/dist/guard.d.ts +29 -8
- package/dist/guard.js +28 -35
- package/dist/index.d.ts +19 -10
- package/dist/index.js +49 -113
- package/dist/inngest.js +6 -56
- package/dist/resume.d.ts +83 -26
- package/dist/resume.js +213 -150
- package/dist/signing.d.ts +72 -6
- package/dist/signing.js +107 -41
- package/openapi.yaml +47 -1
- package/package.json +3 -9
package/dist/resume.js
CHANGED
|
@@ -1,28 +1,13 @@
|
|
|
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
|
-
|
|
25
|
-
|
|
4
|
+
import { userInfo } from "node:os";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { signConfirm, canonicalize as jcs, assertSafeTransport } from "./signing.js";
|
|
7
|
+
import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
8
|
+
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
9
|
+
import { DecidioHttpError } from "./errors.js";
|
|
10
|
+
const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
|
|
26
11
|
function signArgs(p, agentKey, webhookSecret) {
|
|
27
12
|
const bytes = argsBindingBytes(p);
|
|
28
13
|
if (agentKey) {
|
|
@@ -30,7 +15,7 @@ function signArgs(p, agentKey, webhookSecret) {
|
|
|
30
15
|
const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
|
|
31
16
|
return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
|
|
32
17
|
}
|
|
33
|
-
catch {
|
|
18
|
+
catch { }
|
|
34
19
|
}
|
|
35
20
|
if (webhookSecret)
|
|
36
21
|
return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
|
|
@@ -67,17 +52,32 @@ export class MemoryPendingStore {
|
|
|
67
52
|
async claim(id) { if (this.claimed.has(id) || !this.m.has(id))
|
|
68
53
|
return false; this.claimed.add(id); return true; }
|
|
69
54
|
async release(id) { this.claimed.delete(id); }
|
|
55
|
+
async listClaimed() { return [...this.claimed].map((id) => this.m.get(id)).filter(Boolean); }
|
|
56
|
+
}
|
|
57
|
+
export function hardenDir(dir) {
|
|
58
|
+
if (process.platform !== "win32")
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
const user = (() => { try {
|
|
62
|
+
return userInfo().username;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return process.env.USERNAME ?? process.env.USER ?? "";
|
|
66
|
+
} })();
|
|
67
|
+
if (!user)
|
|
68
|
+
throw new Error("could not determine the current user");
|
|
69
|
+
execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { stdio: "ignore" });
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
console.warn(`[decidio] WARNING: could not restrict ${dir} to owner-only (${e?.message ?? e}). It keeps the directory's inherited permissions, so other accounts on this machine may be able to read parked action arguments. Move the pending store to a protected location, or set DECIDIO_PENDING_DIR to one.`);
|
|
73
|
+
}
|
|
70
74
|
}
|
|
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
75
|
export class FilePendingStore {
|
|
77
76
|
dir;
|
|
78
77
|
constructor(dir) {
|
|
79
78
|
this.dir = dir;
|
|
80
79
|
mkdirSync(dir, { recursive: true });
|
|
80
|
+
hardenDir(dir);
|
|
81
81
|
}
|
|
82
82
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
83
83
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
@@ -95,12 +95,8 @@ export class FilePendingStore {
|
|
|
95
95
|
async list() {
|
|
96
96
|
if (!existsSync(this.dir))
|
|
97
97
|
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
98
|
const out = [];
|
|
103
|
-
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
|
|
99
|
+
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
|
|
104
100
|
try {
|
|
105
101
|
out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
|
|
106
102
|
}
|
|
@@ -114,7 +110,7 @@ export class FilePendingStore {
|
|
|
114
110
|
try {
|
|
115
111
|
renameSync(this.path(id), this.claimedPath(id));
|
|
116
112
|
return true;
|
|
117
|
-
}
|
|
113
|
+
}
|
|
118
114
|
catch {
|
|
119
115
|
return false;
|
|
120
116
|
}
|
|
@@ -123,13 +119,23 @@ export class FilePendingStore {
|
|
|
123
119
|
try {
|
|
124
120
|
renameSync(this.claimedPath(id), this.path(id));
|
|
125
121
|
}
|
|
126
|
-
catch {
|
|
122
|
+
catch { }
|
|
123
|
+
}
|
|
124
|
+
async listClaimed() {
|
|
125
|
+
if (!existsSync(this.dir))
|
|
126
|
+
return [];
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const name of readdirSync(this.dir)) {
|
|
129
|
+
if (!name.endsWith(".claimed"))
|
|
130
|
+
continue;
|
|
131
|
+
try {
|
|
132
|
+
out.push(JSON.parse(readFileSync(join(this.dir, name), "utf8")));
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
127
137
|
}
|
|
128
138
|
}
|
|
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
139
|
export class DecidioSuspendedError extends Error {
|
|
134
140
|
decisionId;
|
|
135
141
|
action;
|
|
@@ -144,8 +150,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
144
150
|
function sign(secret, body) {
|
|
145
151
|
return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
|
|
146
152
|
}
|
|
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
153
|
export function verifyResumeSignature(secret, body, header) {
|
|
150
154
|
if (!header)
|
|
151
155
|
return false;
|
|
@@ -153,11 +157,6 @@ export function verifyResumeSignature(secret, body, header) {
|
|
|
153
157
|
const a = Buffer.from(expected), b = Buffer.from(header);
|
|
154
158
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
155
159
|
}
|
|
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
160
|
export function assertConfirmRecorded(body, decisionId, signed) {
|
|
162
161
|
if (!body || body.ok !== true || typeof body.confirmation !== "string") {
|
|
163
162
|
throw new Error(`confirm answered 2xx but reported no evidence tier — treating the confirmation as NOT recorded`);
|
|
@@ -167,55 +166,139 @@ export function assertConfirmRecorded(body, decisionId, signed) {
|
|
|
167
166
|
}
|
|
168
167
|
return body.confirmation;
|
|
169
168
|
}
|
|
170
|
-
|
|
171
|
-
async function confirm(cfg, decisionId, capturedResponse) {
|
|
172
|
-
const f = cfg.fetchImpl ?? fetch;
|
|
173
|
-
// Sign the confirm so the DURABLE/resume path can earn application_confirmed too (bound to
|
|
174
|
-
// {action:"confirm", scope:decisionId}); without a key the server caps it at agent_asserted.
|
|
169
|
+
function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
|
|
175
170
|
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
171
|
if (!requesterIdentity)
|
|
179
|
-
console.warn(`[decidio] execution evidence for ${decisionId} recorded as
|
|
172
|
+
console.warn(`[decidio] execution evidence for ${decisionId} will be recorded as an unproven agent assertion — no signing key configured. Set DECIDIO_AGENT_KEY/DID to earn a confirming tier.`);
|
|
173
|
+
return buildConfirmBody({ agentId: cfg.agentId ?? "agent", agentKey: cfg.agentKey, agentDid: cfg.agentDid, workspaceId: cfg.workspaceId }, decisionId, canonicalResult, requesterIdentity);
|
|
174
|
+
}
|
|
175
|
+
async function postConfirm(cfg, decisionId, body) {
|
|
176
|
+
const f = cfg.fetchImpl ?? fetch;
|
|
177
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
180
178
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
181
179
|
method: "POST",
|
|
182
180
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
183
|
-
body: JSON.stringify(
|
|
181
|
+
body: JSON.stringify(body),
|
|
184
182
|
});
|
|
185
|
-
if (!r.ok)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
if (!r.ok) {
|
|
184
|
+
const raw = await r.text().catch(() => "");
|
|
185
|
+
let parsed = raw;
|
|
186
|
+
try {
|
|
187
|
+
parsed = raw ? JSON.parse(raw) : undefined;
|
|
188
|
+
}
|
|
189
|
+
catch { }
|
|
190
|
+
throw new DecidioHttpError(r.status, "/agent/confirm", parsed);
|
|
191
|
+
}
|
|
192
|
+
const parsed = await r.json().catch(() => null);
|
|
193
|
+
return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled: true });
|
|
189
194
|
}
|
|
190
195
|
async function statusOf(cfg, decisionId) {
|
|
191
196
|
const f = cfg.fetchImpl ?? fetch;
|
|
197
|
+
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
192
198
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
|
|
193
199
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
194
200
|
});
|
|
195
201
|
if (!r.ok)
|
|
196
|
-
throw new
|
|
202
|
+
throw new DecidioHttpError(r.status, `/agent/status/${decisionId}`);
|
|
197
203
|
return r.json();
|
|
198
204
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
class ReportJournal {
|
|
206
|
+
dir;
|
|
207
|
+
constructor(dir) {
|
|
208
|
+
this.dir = dir;
|
|
209
|
+
try {
|
|
210
|
+
mkdirSync(dir, { recursive: true });
|
|
211
|
+
hardenDir(dir);
|
|
212
|
+
}
|
|
213
|
+
catch { }
|
|
214
|
+
}
|
|
215
|
+
p(id) { return join(this.dir, `${id.replace(/[^\w.-]/g, "_")}.json`); }
|
|
216
|
+
write(e) { try {
|
|
217
|
+
writeFileSync(this.p(e.decisionId), JSON.stringify(e, null, 2), { mode: 0o600 });
|
|
218
|
+
}
|
|
219
|
+
catch { } }
|
|
220
|
+
read(id) { try {
|
|
221
|
+
return existsSync(this.p(id)) ? JSON.parse(readFileSync(this.p(id), "utf8")) : null;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
} }
|
|
226
|
+
clear(id) { try {
|
|
227
|
+
if (existsSync(this.p(id)))
|
|
228
|
+
rmSync(this.p(id));
|
|
229
|
+
}
|
|
230
|
+
catch { } }
|
|
231
|
+
list() {
|
|
232
|
+
try {
|
|
233
|
+
if (!existsSync(this.dir))
|
|
234
|
+
return [];
|
|
235
|
+
return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => { try {
|
|
236
|
+
return JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return null;
|
|
240
|
+
} }).filter(Boolean);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
209
247
|
export function createDecidioResume(opts) {
|
|
210
248
|
const registry = new Map();
|
|
211
|
-
const inFlight = new Set();
|
|
249
|
+
const inFlight = new Set();
|
|
212
250
|
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
251
|
if (opts.allowUnsigned)
|
|
218
252
|
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.");
|
|
253
|
+
const journal = new ReportJournal(opts.reportDir ?? join(process.env.DECIDIO_PENDING_DIR ?? ".decidio-pending", "reports"));
|
|
254
|
+
async function reportAfterExecution(pending, decisionId, result) {
|
|
255
|
+
const base = { status: "executed", decisionId, action: pending.action, result };
|
|
256
|
+
let reported;
|
|
257
|
+
let haveReported = false;
|
|
258
|
+
let buildProblem;
|
|
259
|
+
try {
|
|
260
|
+
reported = toCanonical((ctrl.evidence ? ctrl.evidence(result) : result) ?? null, "evidence(result)");
|
|
261
|
+
haveReported = true;
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
buildProblem = `${e?.message ?? e}`;
|
|
265
|
+
reported = null;
|
|
266
|
+
haveReported = true;
|
|
267
|
+
}
|
|
268
|
+
if (haveReported)
|
|
269
|
+
journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
|
|
270
|
+
let confirmRequest;
|
|
271
|
+
try {
|
|
272
|
+
confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
confirmRequest = undefined;
|
|
276
|
+
}
|
|
277
|
+
if (!confirmRequest) {
|
|
278
|
+
await ctrl.store.delete(decisionId).catch(() => { });
|
|
279
|
+
const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it; record the outcome from your own system.`;
|
|
280
|
+
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
281
|
+
return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
|
|
282
|
+
}
|
|
283
|
+
await ctrl.store.delete(decisionId).catch(() => { });
|
|
284
|
+
let ev;
|
|
285
|
+
try {
|
|
286
|
+
ev = await postConfirm(ctrl, decisionId, confirmRequest);
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
ev = { reportRecorded: false, executionConfirmed: false, diagnostic: `the execution report did not reach Decidio (${e?.message ?? e}). Your action DID run and the report is journalled - re-send it with reconfirm("${decisionId}").` };
|
|
290
|
+
}
|
|
291
|
+
if (ev.reportRecorded)
|
|
292
|
+
journal.clear(decisionId);
|
|
293
|
+
const diagnostic = buildProblem
|
|
294
|
+
? `${ev.diagnostic ? ev.diagnostic + " " : ""}Also: the action's return value was not reportable (${buildProblem}), so an empty response was reported - map it with the \`evidence\` option to earn a confirming tier.`
|
|
295
|
+
: ev.diagnostic;
|
|
296
|
+
if (!ev.reportRecorded)
|
|
297
|
+
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
298
|
+
else if (!ev.executionConfirmed)
|
|
299
|
+
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
|
|
300
|
+
return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(diagnostic ? { diagnostic } : {}) };
|
|
301
|
+
}
|
|
219
302
|
const refusalWarned = new Set();
|
|
220
303
|
const refuseOnce = (decisionId, msg) => {
|
|
221
304
|
if (refusalWarned.has(decisionId))
|
|
@@ -225,29 +308,25 @@ export function createDecidioResume(opts) {
|
|
|
225
308
|
};
|
|
226
309
|
const ctrl = {
|
|
227
310
|
...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
311
|
agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
|
|
231
312
|
agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
|
|
232
313
|
agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
|
|
233
|
-
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
314
|
+
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
234
315
|
webhookSecret: secret,
|
|
235
316
|
store: opts.store,
|
|
236
317
|
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
318
|
const prev = registry.get(action);
|
|
241
319
|
if (prev && prev !== fn)
|
|
242
320
|
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
321
|
registry.set(action, fn);
|
|
244
322
|
},
|
|
245
323
|
async park(p) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
324
|
+
try {
|
|
325
|
+
toCanonical(p.args, "args");
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
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);
|
|
329
|
+
}
|
|
251
330
|
const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
|
|
252
331
|
if (!argsSig)
|
|
253
332
|
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 +337,7 @@ export function createDecidioResume(opts) {
|
|
|
258
337
|
opts.onResolved?.(o); return o; };
|
|
259
338
|
const pending = await ctrl.store.get(decisionId);
|
|
260
339
|
if (!pending)
|
|
261
|
-
return { status: "unknown", decisionId };
|
|
340
|
+
return { status: "unknown", decisionId };
|
|
262
341
|
if (verdict === "rejected") {
|
|
263
342
|
await ctrl.store.delete(decisionId);
|
|
264
343
|
return finish({ status: "rejected", decisionId, action: pending.action, reason });
|
|
@@ -267,24 +346,12 @@ export function createDecidioResume(opts) {
|
|
|
267
346
|
await ctrl.store.delete(decisionId);
|
|
268
347
|
return finish({ status: "blocked", decisionId, action: pending.action, reason });
|
|
269
348
|
}
|
|
270
|
-
// Fail CLOSED on any unexpected verdict — never execute on a garbage/unknown value.
|
|
271
349
|
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
272
350
|
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
351
|
if (pending.decisionId !== decisionId) {
|
|
280
352
|
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
353
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
|
|
282
354
|
}
|
|
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
355
|
const strictBinding = !!ctrl.agentKey;
|
|
289
356
|
const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
|
|
290
357
|
if (integrity === "mismatch") {
|
|
@@ -296,9 +363,6 @@ export function createDecidioResume(opts) {
|
|
|
296
363
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
|
|
297
364
|
}
|
|
298
365
|
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
366
|
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
367
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
|
|
304
368
|
}
|
|
@@ -306,8 +370,6 @@ export function createDecidioResume(opts) {
|
|
|
306
370
|
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
371
|
const fn = registry.get(pending.action);
|
|
308
372
|
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
373
|
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
312
374
|
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
313
375
|
console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
|
|
@@ -316,44 +378,12 @@ export function createDecidioResume(opts) {
|
|
|
316
378
|
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
317
379
|
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
380
|
}
|
|
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
381
|
const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
|
|
322
382
|
if (!claimed)
|
|
323
|
-
return { status: "unknown", decisionId };
|
|
383
|
+
return { status: "unknown", decisionId };
|
|
324
384
|
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
385
|
const result = await fn(...pending.args);
|
|
328
|
-
|
|
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
|
-
let confirmed = true;
|
|
333
|
-
let tier;
|
|
334
|
-
try {
|
|
335
|
-
tier = await confirm(ctrl, decisionId, result);
|
|
336
|
-
}
|
|
337
|
-
catch (e) {
|
|
338
|
-
confirmed = false;
|
|
339
|
-
console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
|
|
340
|
-
}
|
|
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
|
-
const retainedIsSafe = typeof ctrl.store.claim === "function";
|
|
348
|
-
if (confirmed || !retainedIsSafe)
|
|
349
|
-
await ctrl.store.delete(decisionId);
|
|
350
|
-
if (!confirmed) {
|
|
351
|
-
if (retainedIsSafe)
|
|
352
|
-
console.warn(` The parked entry for ${decisionId} is kept as local evidence that it executed — it stays CLAIMED, so it cannot re-run. Retry the confirmation, then delete it.`);
|
|
353
|
-
else
|
|
354
|
-
console.warn(` This store provides no claim() primitive, so the entry was DELETED to guarantee the action cannot execute twice. ${decisionId} executed, but its confirmation never reached Decidio — re-confirm it from your own records.`);
|
|
355
|
-
}
|
|
356
|
-
return finish({ status: "executed", decisionId, action: pending.action, result, confirmed, ...(tier ? { evidenceTier: tier } : {}) });
|
|
386
|
+
return finish(await reportAfterExecution(pending, decisionId, result));
|
|
357
387
|
}
|
|
358
388
|
catch (e) {
|
|
359
389
|
if (ctrl.store.release)
|
|
@@ -374,9 +404,6 @@ export function createDecidioResume(opts) {
|
|
|
374
404
|
req.on("data", (c) => (raw += c));
|
|
375
405
|
req.on("end", async () => {
|
|
376
406
|
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
407
|
if (!secret) {
|
|
381
408
|
if (!opts.allowUnsigned) {
|
|
382
409
|
res.statusCode = 401;
|
|
@@ -423,6 +450,48 @@ export function createDecidioResume(opts) {
|
|
|
423
450
|
}
|
|
424
451
|
};
|
|
425
452
|
},
|
|
453
|
+
async reconfirm(decisionId) {
|
|
454
|
+
const entry = journal.read(decisionId);
|
|
455
|
+
if (!entry) {
|
|
456
|
+
return {
|
|
457
|
+
reportRecorded: false, executionConfirmed: false,
|
|
458
|
+
diagnostic: `nothing is journalled for ${decisionId}: either its report was already recorded (nothing to do) or this process never executed it. Check the record in Decidio — do NOT re-run the action to find out.`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
let request;
|
|
462
|
+
try {
|
|
463
|
+
request = buildResumeConfirmRequest(ctrl, decisionId, entry.reported);
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the journalled report for ${decisionId} could not be re-signed (${e?.message ?? e}). The entry is kept; fix the agent's signing config (DECIDIO_AGENT_KEY/DID) and retry.` };
|
|
467
|
+
}
|
|
468
|
+
const ev = await postConfirm(ctrl, decisionId, request);
|
|
469
|
+
if (ev.reportRecorded)
|
|
470
|
+
journal.clear(decisionId);
|
|
471
|
+
return ev;
|
|
472
|
+
},
|
|
473
|
+
async recover() {
|
|
474
|
+
const items = [];
|
|
475
|
+
for (const e of journal.list()) {
|
|
476
|
+
items.push({
|
|
477
|
+
decisionId: e.decisionId, action: e.action, state: "executed_unreported",
|
|
478
|
+
guidance: `"${e.action}" executed at ${e.at} and its execution report has not been recorded. Re-send it safely: guard.resume.reconfirm("${e.decisionId}") — this replays the saved report and cannot re-run the action.`,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
const claimed = ctrl.store.listClaimed ? await ctrl.store.listClaimed().catch(() => []) : [];
|
|
482
|
+
for (const p of claimed) {
|
|
483
|
+
if (journal.read(p.decisionId))
|
|
484
|
+
continue;
|
|
485
|
+
items.push({
|
|
486
|
+
decisionId: p.decisionId, action: p.action, state: "indeterminate",
|
|
487
|
+
guidance: `"${p.action}" was CLAIMED for execution but no result was ever recorded: a process died mid-flight, so it is unknown locally whether the action ran. Reconcile in the downstream system (or with an idempotency key) before deciding. The SDK will NOT retry it automatically, and neither should a script.`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
for (const p of await ctrl.store.list().catch(() => [])) {
|
|
491
|
+
items.push({ decisionId: p.decisionId, action: p.action, state: "parked", guidance: `"${p.action}" is parked awaiting a human decision. Nothing has executed.` });
|
|
492
|
+
}
|
|
493
|
+
return items;
|
|
494
|
+
},
|
|
426
495
|
worker(workerOpts) {
|
|
427
496
|
const interval = workerOpts?.intervalMs ?? 2_500;
|
|
428
497
|
const deadline = workerOpts?.timeoutMs ? Date.now() + workerOpts.timeoutMs : Infinity;
|
|
@@ -443,18 +512,12 @@ export function createDecidioResume(opts) {
|
|
|
443
512
|
st.state === "blocked" ? "blocked" : null;
|
|
444
513
|
if (!verdict)
|
|
445
514
|
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
515
|
try {
|
|
449
516
|
const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
// total silence — the only evidence was a file vanishing). onResolved remains the
|
|
453
|
-
// programmatic hook; this is the human-visible floor.
|
|
454
|
-
if (outcome.status === "executed" && outcome.confirmed)
|
|
455
|
-
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, evidence recorded as ${outcome.evidenceTier ?? "confirmed"}.`);
|
|
517
|
+
if (outcome.status === "executed" && outcome.reportRecorded)
|
|
518
|
+
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed; evidence recorded as ${outcome.evidenceTier}${outcome.executionConfirmed ? "" : " (execution NOT confirmed — see the warning above)"}.`);
|
|
456
519
|
else if (outcome.status === "executed")
|
|
457
|
-
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution
|
|
520
|
+
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution report did NOT reach Decidio — recover with: guard.resume.reconfirm("${p.decisionId}")`);
|
|
458
521
|
else if (outcome.status === "rejected")
|
|
459
522
|
console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
|
|
460
523
|
else if (outcome.status === "blocked")
|
package/dist/signing.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export declare function canonicalize(v: unknown): string;
|
|
|
3
3
|
/** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
|
|
4
4
|
* and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
|
|
5
5
|
* it never goes back to Decidio). */
|
|
6
|
+
/** Derive the public did:key from a stored private key. Lets `doctor` prove that the key on
|
|
7
|
+
* this machine is actually the one registered for this agent — a mismatched restore otherwise
|
|
8
|
+
* looks healthy until a signed confirmation quietly fails to earn its tier weeks later. */
|
|
9
|
+
export declare function didFromPrivateKey(privateKeyBase64: string): string;
|
|
6
10
|
export declare function generateKeypair(): {
|
|
7
11
|
did: string;
|
|
8
12
|
privateKeyBase64: string;
|
|
@@ -46,11 +50,73 @@ export declare function signBoundProof(privateKeyBase64: string, fields: {
|
|
|
46
50
|
now?: number;
|
|
47
51
|
nonce?: string;
|
|
48
52
|
}): RequesterIdentity;
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* returns nothing. The supported data model is plain JSON: null, boolean, string, finite
|
|
53
|
+
/** Refuse values that do not survive `JSON.stringify → JSON.parse` with their meaning intact,
|
|
54
|
+
* BEFORE anything is routed, signed, or parked. `NaN` canonicalizes to `null` and a `Map` to
|
|
55
|
+
* `{}` — an integrity signature over those bytes would verify while the function executed
|
|
56
|
+
* arguments that meant something else, so the gate refuses them instead. Throws naming the
|
|
57
|
+
* offending path. The supported data model is plain JSON: null, boolean, string, finite
|
|
55
58
|
* number, plain array, plain object. */
|
|
59
|
+
/** Typed refusal for values that cannot survive JSON with their meaning intact. `decisionId`
|
|
60
|
+
* is set only when a decision already exists (defense-in-depth refusals after routing). */
|
|
61
|
+
export declare class DecidioUnsafeArgumentsError extends Error {
|
|
62
|
+
decisionId?: string | undefined;
|
|
63
|
+
constructor(message: string, decisionId?: string | undefined);
|
|
64
|
+
}
|
|
56
65
|
export declare function assertJsonSafe(value: unknown, path?: string): void;
|
|
66
|
+
/** The wire rule: a credentialed request must never ride plaintext http to a remote host — the
|
|
67
|
+
* rule is about the WIRE, not which credential (the interactive sign-in enforced this for
|
|
68
|
+
* session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
|
|
69
|
+
* allowed for local dev; https always allowed. Throws before anything is sent. */
|
|
70
|
+
export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
|
|
71
|
+
/** A signed, DOMAIN-SEPARATED, versioned attestation that THIS agent reported THIS captured
|
|
72
|
+
* response for THIS decision. Distinct from the identity proof (`signConfirm`) in every field
|
|
73
|
+
* that matters, so a report can never be lifted from one decision onto another: the decision id,
|
|
74
|
+
* the workspace, the agent's did, the source, the response digest, the digest algorithm, the
|
|
75
|
+
* canonicalization version, a nonce and an expiry are ALL inside the signed bytes.
|
|
76
|
+
*
|
|
77
|
+
* HONEST SCOPE — say this out loud rather than let a reader assume more: this proves integrity
|
|
78
|
+
* and agent attribution of the REPORT. It does not prove the downstream system committed
|
|
79
|
+
* anything; only the source system can attest that, and the gate's evidence tier is what
|
|
80
|
+
* records how strong that attestation is. */
|
|
81
|
+
export interface ExecutionReport {
|
|
82
|
+
v: 1;
|
|
83
|
+
purpose: "decidio.execution-report";
|
|
84
|
+
decisionId: string;
|
|
85
|
+
agentId: string;
|
|
86
|
+
did: string;
|
|
87
|
+
workspaceId?: string;
|
|
88
|
+
source: string;
|
|
89
|
+
resultDigest: string;
|
|
90
|
+
digestAlg: "sha-256";
|
|
91
|
+
canonicalization: "decidio-jcs-1";
|
|
92
|
+
nonce: string;
|
|
93
|
+
exp: number;
|
|
94
|
+
proof: {
|
|
95
|
+
protected: string;
|
|
96
|
+
signature: string;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export declare function signExecutionReport(privateKeyBase64: string, fields: {
|
|
100
|
+
decisionId: string;
|
|
101
|
+
agentId: string;
|
|
102
|
+
did: string;
|
|
103
|
+
workspaceId?: string;
|
|
104
|
+
source: string;
|
|
105
|
+
resultDigest: string;
|
|
106
|
+
ttlMs?: number;
|
|
107
|
+
now?: number;
|
|
108
|
+
nonce?: string;
|
|
109
|
+
}): ExecutionReport;
|
|
110
|
+
/** Verify an ExecutionReport LOCALLY — same rules the server applies before persisting it.
|
|
111
|
+
* Returns why it failed rather than a bare false, so a caller can say something useful. */
|
|
112
|
+
export declare function verifyExecutionReport(report: unknown, expect: {
|
|
113
|
+
decisionId: string;
|
|
114
|
+
did?: string;
|
|
115
|
+
now?: number;
|
|
116
|
+
}): {
|
|
117
|
+
valid: boolean;
|
|
118
|
+
reason?: string;
|
|
119
|
+
};
|
|
120
|
+
/** sha-256 over the canonical JSON of an already-canonical value — the digest an ExecutionReport
|
|
121
|
+
* binds. Both languages produce identical bytes for identical values. */
|
|
122
|
+
export declare function resultDigest(canonicalValue: unknown): string;
|