@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/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 { signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
24
- // ---- parked-payload integrity (round-6 acceptance catch) -------------------------------------
25
- 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");
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 { /* fall through to hmac */ }
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"))) { // .claimed excluded — in flight
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
- } // atomic; fails if already claimed/gone
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 { /* already released/gone */ }
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
- /** Resume-door confirm: POST + the shared body rule above. */
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 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.`);
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({ decisionId, capturedResponse: capturedResponse ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
181
+ body: JSON.stringify(body),
184
182
  });
185
- if (!r.ok)
186
- throw new Error(`confirm ${r.status}`);
187
- const body = await r.json().catch(() => null);
188
- return assertConfirmRecorded(body, decisionId, !!requesterIdentity);
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 Error(`status ${r.status}`);
202
+ throw new DecidioHttpError(r.status, `/agent/status/${decisionId}`);
197
203
  return r.json();
198
204
  }
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
- */
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(); // concurrent-duplicate guard (same process)
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, // D-B3.6: tenant-bind the cold-resume confirm proof
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
- // GATE, not serialization (prodsec F-01): a value that cannot survive JSON with its meaning
247
- // intact must be REFUSED before it is signed or parked — the signature covers canonical
248
- // bytes, and NaN/Map collide with null/{} in those bytes, so it would verify and execute
249
- // with drifted semantics. Refusal here means no decision, no signature, no file.
250
- assertJsonSafe(p.args, "args");
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 }; // not ours / already resumed
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 }; // another worker owns it
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
- // 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
- 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
- // 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
- 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 evidence did NOT reach Decidio — see the warning above.`);
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
- /** The values that do not survive `JSON.stringify → JSON.parse` with their meaning intact.
50
- * Refused at the GATE, before anything is routed, signed, or parked (prodsec F-01: `NaN`
51
- * canonicalized to `null` and a `Map` to `{}`, so the integrity signature matched the drifted
52
- * bytes and the function EXECUTED with arguments that meant something else — the exact wrong
53
- * execution the parked-payload signature exists to refuse). Throws naming the offending path;
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;