@decidio/sdk 0.1.10 → 0.1.12
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 +126 -25
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +76 -9
- package/dist/adapters/langgraph.js +2 -1
- package/dist/adapters/openai.js +2 -1
- package/dist/adapters/temporal.js +17 -3
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +128 -0
- package/dist/cli.js +182 -30
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/evidence.js +65 -0
- package/dist/guard.d.ts +22 -3
- package/dist/guard.js +18 -11
- package/dist/index.d.ts +13 -2
- package/dist/index.js +58 -29
- package/dist/inngest.js +6 -30
- package/dist/resume.d.ts +78 -17
- package/dist/resume.js +226 -55
- package/dist/signing.d.ts +64 -0
- package/dist/signing.js +102 -3
- package/openapi.yaml +47 -1
- package/package.json +2 -2
package/dist/resume.js
CHANGED
|
@@ -3,8 +3,11 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { userInfo } from "node:os";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
|
-
import { signConfirm, canonicalize
|
|
7
|
-
|
|
6
|
+
import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } 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");
|
|
8
11
|
function signArgs(p, agentKey, webhookSecret) {
|
|
9
12
|
const bytes = argsBindingBytes(p);
|
|
10
13
|
if (agentKey) {
|
|
@@ -49,24 +52,32 @@ export class MemoryPendingStore {
|
|
|
49
52
|
async claim(id) { if (this.claimed.has(id) || !this.m.has(id))
|
|
50
53
|
return false; this.claimed.add(id); return true; }
|
|
51
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
|
+
}
|
|
52
74
|
}
|
|
53
75
|
export class FilePendingStore {
|
|
54
76
|
dir;
|
|
55
77
|
constructor(dir) {
|
|
56
78
|
this.dir = dir;
|
|
57
79
|
mkdirSync(dir, { recursive: true });
|
|
58
|
-
|
|
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
|
-
}
|
|
80
|
+
hardenDir(dir);
|
|
70
81
|
}
|
|
71
82
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
72
83
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
@@ -110,6 +121,20 @@ export class FilePendingStore {
|
|
|
110
121
|
}
|
|
111
122
|
catch { }
|
|
112
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;
|
|
137
|
+
}
|
|
113
138
|
}
|
|
114
139
|
export class DecidioSuspendedError extends Error {
|
|
115
140
|
decisionId;
|
|
@@ -141,38 +166,160 @@ export function assertConfirmRecorded(body, decisionId, signed) {
|
|
|
141
166
|
}
|
|
142
167
|
return body.confirmation;
|
|
143
168
|
}
|
|
144
|
-
|
|
145
|
-
const f = cfg.fetchImpl ?? fetch;
|
|
146
|
-
assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
|
|
169
|
+
function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
|
|
147
170
|
const requesterIdentity = signConfirm(decisionId, cfg);
|
|
148
171
|
if (!requesterIdentity)
|
|
149
|
-
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, journalled = true) {
|
|
176
|
+
const f = cfg.fetchImpl ?? fetch;
|
|
177
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
150
178
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
151
179
|
method: "POST",
|
|
152
180
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
153
|
-
body: JSON.stringify(
|
|
181
|
+
body: JSON.stringify(body),
|
|
154
182
|
});
|
|
155
|
-
if (!r.ok)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 });
|
|
159
194
|
}
|
|
160
195
|
async function statusOf(cfg, decisionId) {
|
|
161
196
|
const f = cfg.fetchImpl ?? fetch;
|
|
162
|
-
assertSafeTransport(cfg.apiUrl,
|
|
197
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
163
198
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
|
|
164
199
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
165
200
|
});
|
|
166
201
|
if (!r.ok)
|
|
167
|
-
throw new
|
|
202
|
+
throw new DecidioHttpError(r.status, `/agent/status/${decisionId}`);
|
|
168
203
|
return r.json();
|
|
169
204
|
}
|
|
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 (e) {
|
|
214
|
+
console.error(`[decidio] could not create the execution-report journal directory ${dir} (${e?.message ?? e}) — reports will NOT be locally recoverable until this is fixed. Point it somewhere writable with reportDir (or DECIDIO_PENDING_DIR).`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
p(id) { return join(this.dir, `${id.replace(/[^\w.-]/g, "_")}.json`); }
|
|
218
|
+
write(e) {
|
|
219
|
+
try {
|
|
220
|
+
writeFileSync(this.p(e.decisionId), JSON.stringify(e, null, 2), { mode: 0o600 });
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
console.error(`[decidio] ${e.decisionId}: the action RAN but its execution report could NOT be journalled to ${this.dir} (${err?.message ?? err}). If the report also fails to send, this execution is NOT locally recoverable — record the outcome from your own system. Fix the journal location with reportDir (or DECIDIO_PENDING_DIR).`);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
read(id) { try {
|
|
229
|
+
return existsSync(this.p(id)) ? JSON.parse(readFileSync(this.p(id), "utf8")) : null;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
} }
|
|
234
|
+
clear(id) { try {
|
|
235
|
+
if (existsSync(this.p(id)))
|
|
236
|
+
rmSync(this.p(id));
|
|
237
|
+
}
|
|
238
|
+
catch { } }
|
|
239
|
+
list() {
|
|
240
|
+
try {
|
|
241
|
+
if (!existsSync(this.dir))
|
|
242
|
+
return [];
|
|
243
|
+
return readdirSync(this.dir).filter((f) => f.endsWith(".json")).map((f) => { try {
|
|
244
|
+
return JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
} }).filter(Boolean);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
170
255
|
export function createDecidioResume(opts) {
|
|
171
256
|
const registry = new Map();
|
|
172
257
|
const inFlight = new Set();
|
|
173
258
|
const secret = opts.webhookSecret ?? opts.apiToken ?? "";
|
|
174
259
|
if (opts.allowUnsigned)
|
|
175
260
|
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.");
|
|
261
|
+
const journal = new ReportJournal(opts.reportDir ?? join(process.env.DECIDIO_PENDING_DIR ?? ".decidio-pending", "reports"));
|
|
262
|
+
async function reportAfterExecution(pending, decisionId, result) {
|
|
263
|
+
const base = { status: "executed", decisionId, action: pending.action, result };
|
|
264
|
+
let reported;
|
|
265
|
+
let haveReported = false;
|
|
266
|
+
let buildProblem;
|
|
267
|
+
const evidenceMapper = registry.get(pending.action)?.evidence ?? ctrl.evidence;
|
|
268
|
+
try {
|
|
269
|
+
reported = toCanonical((evidenceMapper ? evidenceMapper(result) : result) ?? null, "evidence(result)");
|
|
270
|
+
haveReported = true;
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
buildProblem = `${e?.message ?? e}`;
|
|
274
|
+
reported = null;
|
|
275
|
+
haveReported = true;
|
|
276
|
+
}
|
|
277
|
+
const journalled = haveReported && journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
|
|
278
|
+
let confirmRequest;
|
|
279
|
+
try {
|
|
280
|
+
confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
confirmRequest = undefined;
|
|
284
|
+
}
|
|
285
|
+
const deletePending = async () => {
|
|
286
|
+
try {
|
|
287
|
+
await ctrl.store.delete(decisionId);
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
console.error(`[decidio] ${decisionId}: the action RAN but its pending entry could NOT be deleted (${e?.message ?? e}) — the entry SURVIVED. Remove it from the pending store by hand before another process or a restart can see it, or the committed action may be executed again.`);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
if (!confirmRequest) {
|
|
294
|
+
await deletePending();
|
|
295
|
+
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.`;
|
|
296
|
+
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
297
|
+
return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
|
|
298
|
+
}
|
|
299
|
+
await deletePending();
|
|
300
|
+
let ev;
|
|
301
|
+
try {
|
|
302
|
+
ev = await postConfirm(ctrl, decisionId, confirmRequest, journalled);
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
ev = {
|
|
306
|
+
reportRecorded: false, executionConfirmed: false,
|
|
307
|
+
diagnostic: journalled
|
|
308
|
+
? `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}").`
|
|
309
|
+
: `the execution report did not reach Decidio (${e?.message ?? e}) AND could not be journalled locally, so nothing here can re-send it. Your action DID run - record the outcome from your own system.`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (ev.reportRecorded)
|
|
313
|
+
journal.clear(decisionId);
|
|
314
|
+
const diagnostic = buildProblem
|
|
315
|
+
? `${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.`
|
|
316
|
+
: ev.diagnostic;
|
|
317
|
+
if (!ev.reportRecorded)
|
|
318
|
+
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
319
|
+
else if (!ev.executionConfirmed || ev.executionReportVerified === false)
|
|
320
|
+
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
|
|
321
|
+
return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(ev.executionReportVerified !== undefined ? { executionReportVerified: ev.executionReportVerified } : {}), ...(diagnostic ? { diagnostic } : {}) };
|
|
322
|
+
}
|
|
176
323
|
const refusalWarned = new Set();
|
|
177
324
|
const refuseOnce = (decisionId, msg) => {
|
|
178
325
|
if (refusalWarned.has(decisionId))
|
|
@@ -188,15 +335,17 @@ export function createDecidioResume(opts) {
|
|
|
188
335
|
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
189
336
|
webhookSecret: secret,
|
|
190
337
|
store: opts.store,
|
|
191
|
-
register(action, fn) {
|
|
338
|
+
register(action, fn, evidence) {
|
|
192
339
|
const prev = registry.get(action);
|
|
193
|
-
if (prev && prev !== fn)
|
|
340
|
+
if (prev && prev.fn !== fn)
|
|
194
341
|
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.`);
|
|
195
|
-
|
|
342
|
+
if (prev && prev.evidence && evidence && prev.evidence !== evidence)
|
|
343
|
+
console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT evidence mapper — the latest one wins, and a resumed decision's record would carry ITS output. Give each protected function its own action name.`);
|
|
344
|
+
registry.set(action, { fn, evidence: evidence ?? prev?.evidence });
|
|
196
345
|
},
|
|
197
346
|
async park(p) {
|
|
198
347
|
try {
|
|
199
|
-
|
|
348
|
+
toCanonical(p.args, "args");
|
|
200
349
|
}
|
|
201
350
|
catch (e) {
|
|
202
351
|
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);
|
|
@@ -242,7 +391,7 @@ export function createDecidioResume(opts) {
|
|
|
242
391
|
}
|
|
243
392
|
if (integrity === "unbound")
|
|
244
393
|
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.`);
|
|
245
|
-
const fn = registry.get(pending.action);
|
|
394
|
+
const fn = registry.get(pending.action)?.fn;
|
|
246
395
|
if (!fn) {
|
|
247
396
|
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
248
397
|
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
@@ -257,34 +406,14 @@ export function createDecidioResume(opts) {
|
|
|
257
406
|
return { status: "unknown", decisionId };
|
|
258
407
|
try {
|
|
259
408
|
const result = await fn(...pending.args);
|
|
260
|
-
|
|
261
|
-
let tier;
|
|
262
|
-
try {
|
|
263
|
-
tier = await confirm(ctrl, decisionId, result);
|
|
264
|
-
}
|
|
265
|
-
catch (e) {
|
|
266
|
-
confirmed = false;
|
|
267
|
-
console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
|
|
268
|
-
}
|
|
269
|
-
const retainedIsSafe = typeof ctrl.store.claim === "function";
|
|
270
|
-
if (confirmed || !retainedIsSafe)
|
|
271
|
-
await ctrl.store.delete(decisionId);
|
|
272
|
-
if (!confirmed) {
|
|
273
|
-
if (retainedIsSafe)
|
|
274
|
-
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.`);
|
|
275
|
-
else
|
|
276
|
-
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.`);
|
|
277
|
-
}
|
|
278
|
-
return finish({ status: "executed", decisionId, action: pending.action, result, confirmed, ...(tier ? { evidenceTier: tier } : {}) });
|
|
409
|
+
return finish(await reportAfterExecution(pending, decisionId, result));
|
|
279
410
|
}
|
|
280
411
|
catch (e) {
|
|
412
|
+
inFlight.delete(decisionId);
|
|
281
413
|
if (ctrl.store.release)
|
|
282
414
|
await ctrl.store.release(decisionId).catch(() => { });
|
|
283
415
|
throw e;
|
|
284
416
|
}
|
|
285
|
-
finally {
|
|
286
|
-
inFlight.delete(decisionId);
|
|
287
|
-
}
|
|
288
417
|
},
|
|
289
418
|
webhookHandler() {
|
|
290
419
|
return (req, res) => {
|
|
@@ -342,6 +471,48 @@ export function createDecidioResume(opts) {
|
|
|
342
471
|
}
|
|
343
472
|
};
|
|
344
473
|
},
|
|
474
|
+
async reconfirm(decisionId) {
|
|
475
|
+
const entry = journal.read(decisionId);
|
|
476
|
+
if (!entry) {
|
|
477
|
+
return {
|
|
478
|
+
reportRecorded: false, executionConfirmed: false,
|
|
479
|
+
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.`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
let request;
|
|
483
|
+
try {
|
|
484
|
+
request = buildResumeConfirmRequest(ctrl, decisionId, entry.reported);
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
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.` };
|
|
488
|
+
}
|
|
489
|
+
const ev = await postConfirm(ctrl, decisionId, request);
|
|
490
|
+
if (ev.reportRecorded)
|
|
491
|
+
journal.clear(decisionId);
|
|
492
|
+
return ev;
|
|
493
|
+
},
|
|
494
|
+
async recover() {
|
|
495
|
+
const items = [];
|
|
496
|
+
for (const e of journal.list()) {
|
|
497
|
+
items.push({
|
|
498
|
+
decisionId: e.decisionId, action: e.action, state: "executed_unreported",
|
|
499
|
+
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.`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const claimed = ctrl.store.listClaimed ? await ctrl.store.listClaimed().catch(() => []) : [];
|
|
503
|
+
for (const p of claimed) {
|
|
504
|
+
if (journal.read(p.decisionId))
|
|
505
|
+
continue;
|
|
506
|
+
items.push({
|
|
507
|
+
decisionId: p.decisionId, action: p.action, state: "indeterminate",
|
|
508
|
+
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.`,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
for (const p of await ctrl.store.list().catch(() => [])) {
|
|
512
|
+
items.push({ decisionId: p.decisionId, action: p.action, state: "parked", guidance: `"${p.action}" is parked awaiting a human decision. Nothing has executed.` });
|
|
513
|
+
}
|
|
514
|
+
return items;
|
|
515
|
+
},
|
|
345
516
|
worker(workerOpts) {
|
|
346
517
|
const interval = workerOpts?.intervalMs ?? 2_500;
|
|
347
518
|
const deadline = workerOpts?.timeoutMs ? Date.now() + workerOpts.timeoutMs : Infinity;
|
|
@@ -364,10 +535,10 @@ export function createDecidioResume(opts) {
|
|
|
364
535
|
continue;
|
|
365
536
|
try {
|
|
366
537
|
const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
|
|
367
|
-
if (outcome.status === "executed" && outcome.
|
|
368
|
-
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed
|
|
538
|
+
if (outcome.status === "executed" && outcome.reportRecorded)
|
|
539
|
+
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed; evidence recorded as ${outcome.evidenceTier}${outcome.executionConfirmed ? "" : " (execution NOT confirmed — see the warning above)"}.`);
|
|
369
540
|
else if (outcome.status === "executed")
|
|
370
|
-
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution
|
|
541
|
+
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}")`);
|
|
371
542
|
else if (outcome.status === "rejected")
|
|
372
543
|
console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
|
|
373
544
|
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;
|
|
@@ -64,3 +68,63 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
|
|
|
64
68
|
* session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
|
|
65
69
|
* allowed for local dev; https always allowed. Throws before anything is sent. */
|
|
66
70
|
export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
|
|
71
|
+
/** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
|
|
72
|
+
* A signed identity proof is a credential too: sending it over remote plaintext disclosed
|
|
73
|
+
* request context and replay/front-run material even with no bearer configured. */
|
|
74
|
+
export declare const carriesCredential: (c: {
|
|
75
|
+
apiToken?: string;
|
|
76
|
+
agentKey?: string;
|
|
77
|
+
agentDid?: string;
|
|
78
|
+
}) => boolean;
|
|
79
|
+
/** A signed, DOMAIN-SEPARATED, versioned attestation that THIS agent reported THIS captured
|
|
80
|
+
* response for THIS decision. Distinct from the identity proof (`signConfirm`) in every field
|
|
81
|
+
* that matters, so a report can never be lifted from one decision onto another: the decision id,
|
|
82
|
+
* the workspace, the agent's did, the source, the response digest, the digest algorithm, the
|
|
83
|
+
* canonicalization version, a nonce and an expiry are ALL inside the signed bytes.
|
|
84
|
+
*
|
|
85
|
+
* HONEST SCOPE — say this out loud rather than let a reader assume more: this proves integrity
|
|
86
|
+
* and agent attribution of the REPORT. It does not prove the downstream system committed
|
|
87
|
+
* anything; only the source system can attest that, and the gate's evidence tier is what
|
|
88
|
+
* records how strong that attestation is. */
|
|
89
|
+
export interface ExecutionReport {
|
|
90
|
+
v: 1;
|
|
91
|
+
purpose: "decidio.execution-report";
|
|
92
|
+
decisionId: string;
|
|
93
|
+
agentId: string;
|
|
94
|
+
did: string;
|
|
95
|
+
workspaceId?: string;
|
|
96
|
+
source: string;
|
|
97
|
+
resultDigest: string;
|
|
98
|
+
digestAlg: "sha-256";
|
|
99
|
+
canonicalization: "decidio-jcs-1";
|
|
100
|
+
nonce: string;
|
|
101
|
+
exp: number;
|
|
102
|
+
proof: {
|
|
103
|
+
protected: string;
|
|
104
|
+
signature: string;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export declare function signExecutionReport(privateKeyBase64: string, fields: {
|
|
108
|
+
decisionId: string;
|
|
109
|
+
agentId: string;
|
|
110
|
+
did: string;
|
|
111
|
+
workspaceId?: string;
|
|
112
|
+
source: string;
|
|
113
|
+
resultDigest: string;
|
|
114
|
+
ttlMs?: number;
|
|
115
|
+
now?: number;
|
|
116
|
+
nonce?: string;
|
|
117
|
+
}): ExecutionReport;
|
|
118
|
+
/** Verify an ExecutionReport LOCALLY — same rules the server applies before persisting it.
|
|
119
|
+
* Returns why it failed rather than a bare false, so a caller can say something useful. */
|
|
120
|
+
export declare function verifyExecutionReport(report: unknown, expect: {
|
|
121
|
+
decisionId: string;
|
|
122
|
+
did?: string;
|
|
123
|
+
now?: number;
|
|
124
|
+
}): {
|
|
125
|
+
valid: boolean;
|
|
126
|
+
reason?: string;
|
|
127
|
+
};
|
|
128
|
+
/** sha-256 over the canonical JSON of an already-canonical value — the digest an ExecutionReport
|
|
129
|
+
* binds. Both languages produce identical bytes for identical values. */
|
|
130
|
+
export declare function resultDigest(canonicalValue: unknown): string;
|
package/dist/signing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { generateKeyPairSync, createPrivateKey, sign as edSign, randomUUID } from "node:crypto";
|
|
1
|
+
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, createHash, randomUUID } from "node:crypto";
|
|
2
2
|
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
3
3
|
function b58encode(buf) {
|
|
4
4
|
const digits = [0];
|
|
@@ -33,6 +33,11 @@ export function canonicalize(v) {
|
|
|
33
33
|
const o = v;
|
|
34
34
|
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(o[k])}`).join(",")}}`;
|
|
35
35
|
}
|
|
36
|
+
export function didFromPrivateKey(privateKeyBase64) {
|
|
37
|
+
const priv = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
38
|
+
const jwk = createPublicKey(priv).export({ format: "jwk" });
|
|
39
|
+
return "did:key:z" + b58encode(Buffer.concat([Buffer.from([0xed, 0x01]), Buffer.from(jwk.x, "base64url")]));
|
|
40
|
+
}
|
|
36
41
|
export function generateKeypair() {
|
|
37
42
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
38
43
|
const jwk = publicKey.export({ format: "jwk" });
|
|
@@ -108,7 +113,101 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
108
113
|
export function assertSafeTransport(url, hasCredential) {
|
|
109
114
|
if (!hasCredential)
|
|
110
115
|
return;
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
let u;
|
|
117
|
+
try {
|
|
118
|
+
u = new URL(url);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "");
|
|
122
|
+
if (/^http:/i.test(spelled)) {
|
|
123
|
+
throw new Error(`[decidio] refusing to send credentials over plaintext http to ${JSON.stringify(url)} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (u.protocol !== "http:")
|
|
128
|
+
return;
|
|
129
|
+
const host = u.hostname.toLowerCase();
|
|
130
|
+
const local = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
|
|
131
|
+
if (local && !u.username && !u.password)
|
|
132
|
+
return;
|
|
133
|
+
throw new Error(`[decidio] refusing to send credentials over plaintext http to ${u.origin} — use https, or localhost for local dev. Nothing was sent.`);
|
|
134
|
+
}
|
|
135
|
+
export const carriesCredential = (c) => !!c.apiToken || !!(c.agentKey && c.agentDid);
|
|
136
|
+
export function signExecutionReport(privateKeyBase64, fields) {
|
|
137
|
+
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
138
|
+
const nonce = fields.nonce ?? randomUUID();
|
|
139
|
+
const exp = (fields.now ?? Date.now()) + (fields.ttlMs ?? 300_000);
|
|
140
|
+
const body = {
|
|
141
|
+
v: 1,
|
|
142
|
+
purpose: "decidio.execution-report",
|
|
143
|
+
decisionId: fields.decisionId,
|
|
144
|
+
agentId: fields.agentId,
|
|
145
|
+
did: fields.did,
|
|
146
|
+
...(fields.workspaceId !== undefined ? { workspaceId: fields.workspaceId } : {}),
|
|
147
|
+
source: fields.source,
|
|
148
|
+
resultDigest: fields.resultDigest,
|
|
149
|
+
digestAlg: "sha-256",
|
|
150
|
+
canonicalization: "decidio-jcs-1",
|
|
151
|
+
nonce,
|
|
152
|
+
exp,
|
|
153
|
+
};
|
|
154
|
+
const payload = Buffer.from(canonicalize(body), "utf8").toString("base64url");
|
|
155
|
+
const protectedHdr = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: fields.did, typ: "decidio-execution-report+jws" }), "utf8").toString("base64url");
|
|
156
|
+
const signature = Buffer.from(edSign(null, Buffer.from(`${protectedHdr}.${payload}`, "utf8"), key)).toString("base64url");
|
|
157
|
+
return { ...body, proof: { protected: protectedHdr, signature } };
|
|
158
|
+
}
|
|
159
|
+
export function verifyExecutionReport(report, expect) {
|
|
160
|
+
const r = report;
|
|
161
|
+
if (!r || r.purpose !== "decidio.execution-report" || r.v !== 1)
|
|
162
|
+
return { valid: false, reason: "not a v1 decidio execution report" };
|
|
163
|
+
if (r.decisionId !== expect.decisionId)
|
|
164
|
+
return { valid: false, reason: "report is bound to a DIFFERENT decision" };
|
|
165
|
+
if (expect.did && r.did !== expect.did)
|
|
166
|
+
return { valid: false, reason: "report was signed by a different agent key" };
|
|
167
|
+
if (typeof r.exp !== "number" || (expect.now ?? Date.now()) > r.exp)
|
|
168
|
+
return { valid: false, reason: "report expired" };
|
|
169
|
+
if (r.digestAlg !== "sha-256" || r.canonicalization !== "decidio-jcs-1")
|
|
170
|
+
return { valid: false, reason: "unsupported digest/canonicalization" };
|
|
171
|
+
try {
|
|
172
|
+
const { proof, ...body } = r;
|
|
173
|
+
const payload = Buffer.from(canonicalize(body), "utf8").toString("base64url");
|
|
174
|
+
const mc = b58decode(String(r.did).slice("did:key:z".length));
|
|
175
|
+
if (mc[0] !== 0xed || mc[1] !== 0x01)
|
|
176
|
+
return { valid: false, reason: "issuer did is not Ed25519" };
|
|
177
|
+
const pub = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(mc.subarray(2)).toString("base64url") }, format: "jwk" });
|
|
178
|
+
const ok = edVerify(null, Buffer.from(`${proof.protected}.${payload}`, "utf8"), pub, Buffer.from(proof.signature, "base64url"));
|
|
179
|
+
return ok ? { valid: true } : { valid: false, reason: "signature does not verify against the agent did" };
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
return { valid: false, reason: `malformed report (${e?.message ?? e})` };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export function resultDigest(canonicalValue) {
|
|
186
|
+
return createHash("sha256").update(canonicalize(canonicalValue), "utf8").digest("hex");
|
|
187
|
+
}
|
|
188
|
+
function b58decode(s) {
|
|
189
|
+
const A = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
190
|
+
const bytes = [0];
|
|
191
|
+
for (const ch of s) {
|
|
192
|
+
const val = A.indexOf(ch);
|
|
193
|
+
if (val < 0)
|
|
194
|
+
throw new Error("bad base58 character");
|
|
195
|
+
let carry = val;
|
|
196
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
197
|
+
carry += bytes[i] * 58;
|
|
198
|
+
bytes[i] = carry & 0xff;
|
|
199
|
+
carry >>= 8;
|
|
200
|
+
}
|
|
201
|
+
while (carry) {
|
|
202
|
+
bytes.push(carry & 0xff);
|
|
203
|
+
carry >>= 8;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const ch of s) {
|
|
207
|
+
if (ch === A[0])
|
|
208
|
+
bytes.push(0);
|
|
209
|
+
else
|
|
210
|
+
break;
|
|
113
211
|
}
|
|
212
|
+
return Buffer.from(bytes.reverse());
|
|
114
213
|
}
|
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.11
|
|
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
|
|
@@ -101,6 +101,52 @@ components:
|
|
|
101
101
|
requesterIdentity:
|
|
102
102
|
$ref: "#/components/schemas/RequesterIdentity"
|
|
103
103
|
description: confirm proof is bound to {action:"confirm", scope:decisionId}
|
|
104
|
+
executionReport:
|
|
105
|
+
$ref: "#/components/schemas/ExecutionReport"
|
|
106
|
+
ExecutionReport:
|
|
107
|
+
type: object
|
|
108
|
+
description: >
|
|
109
|
+
OPTIONAL proof of WHAT was executed: a domain-separated, versioned envelope over a digest
|
|
110
|
+
of the captured response, bound to the decision, the agent and the workspace. The server
|
|
111
|
+
VERIFIES it before persisting anything — it recomputes the digest from `capturedResponse`
|
|
112
|
+
on this same request, requires the signer to be the decision's original requesting agent
|
|
113
|
+
AND still registered and active, and burns the nonce so a report is single-use. A report
|
|
114
|
+
copied onto a different decision, a different agent, or an altered response is refused.
|
|
115
|
+
|
|
116
|
+
A REFUSED REPORT DOES NOT FAIL THE CONFIRM. This envelope grants no authority — the
|
|
117
|
+
evidence tier comes from the response, the identity from the confirm proof — so an
|
|
118
|
+
unverifiable attestation is refused as an ATTESTATION (never stored as evidence, and the
|
|
119
|
+
refusal is itself audited) while the confirmation is still recorded. The response reports
|
|
120
|
+
which happened via `executionReport.verified`; it is never silent. Omit the field entirely
|
|
121
|
+
and confirm behaves exactly as before.
|
|
122
|
+
required: [v, purpose, decisionId, agentId, did, source, resultDigest, digestAlg, canonicalization, nonce, exp, proof]
|
|
123
|
+
properties:
|
|
124
|
+
v: { type: integer, enum: [1] }
|
|
125
|
+
purpose: { type: string, enum: ["decidio.execution-report"], description: domain separation — this signature can never be replayed as another Decidio proof }
|
|
126
|
+
decisionId: { type: string }
|
|
127
|
+
agentId: { type: string }
|
|
128
|
+
did: { type: string, description: "did:key of the signing agent" }
|
|
129
|
+
workspaceId: { type: string, description: tenant binding; present when the agent knows it }
|
|
130
|
+
source: { type: string, enum: [wrapper, agent] }
|
|
131
|
+
resultDigest:
|
|
132
|
+
type: string
|
|
133
|
+
pattern: "^[0-9a-f]{64}$"
|
|
134
|
+
description: lowercase-hex SHA-256 over the canonicalized captured response (the server recomputes it and compares)
|
|
135
|
+
digestAlg: { type: string, enum: ["sha-256"] }
|
|
136
|
+
canonicalization: { type: string, enum: ["decidio-jcs-1"], description: RFC 8785 JCS with Decidio's refusal rules }
|
|
137
|
+
nonce:
|
|
138
|
+
type: string
|
|
139
|
+
pattern: "^[A-Za-z0-9._:-]{8,64}$"
|
|
140
|
+
description: single-use; burned server-side, so a replayed report is refused
|
|
141
|
+
exp:
|
|
142
|
+
type: integer
|
|
143
|
+
description: unix ms expiry. The server also refuses an expiry more than 15 minutes ahead of now — a client-chosen lifetime is not freshness.
|
|
144
|
+
proof:
|
|
145
|
+
type: object
|
|
146
|
+
required: [protected, signature]
|
|
147
|
+
properties:
|
|
148
|
+
protected: { type: string, description: "base64url JWS protected header (EdDSA, kid=did, typ=decidio-execution-report+jws)" }
|
|
149
|
+
signature: { type: string, description: base64url detached signature over protected.payload }
|
|
104
150
|
ResumeWebhook:
|
|
105
151
|
type: object
|
|
106
152
|
description: >
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@decidio/verify": "0.1.
|
|
84
|
+
"@decidio/verify": "0.1.10"
|
|
85
85
|
},
|
|
86
86
|
"author": "OmniTwin Technologies Inc.",
|
|
87
87
|
"bugs": {
|