@decidio/sdk 0.1.6 → 0.1.7

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 CHANGED
@@ -51,7 +51,8 @@ Every later command reads `.env` from this directory automatically.
51
51
 
52
52
  What `.env` contains after a successful init: `DECIDIO_API_URL`, `DECIDIO_AGENT_ID`,
53
53
  `DECIDIO_AGENT_DID` + `DECIDIO_AGENT_KEY` (the local keypair — the key is what earns
54
- `application_confirmed` evidence later), `DECIDIO_API_TOKEN` (the floor-limited agent token),
54
+ `application_confirmed` evidence later), `DECIDIO_API_TOKEN` (the floor-limited agent token —
55
+ 90 days by default, `--expires-days N` to change),
55
56
  `DECIDIO_WORKSPACE_ID`, and `DECIDIO_WEBHOOK_SECRET` (operator deployments only). Pick any
56
57
  agent name — if it's already taken in your workspace, `init` stops cleanly and suggests a
57
58
  fresh one (or pass `--unique` to auto-append a random suffix — collision-proof for shared
@@ -122,8 +123,13 @@ implementation as the standalone `@decidio/verify`). The receipt is a W3C Verifi
122
123
  Credential: signature and content binding verify with no Decidio account and no network — the
123
124
  evidence is yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
124
125
  `npx @decidio/sdk verify --issuer <did:key:...> receipt.json` (the DID is shown in your app's
125
- record verify panel). One receipt proves itself; chain order across receipts is verified when
126
- you pass several files at once.
126
+ record verify panel — or simply read it off the `receipt` command's output, which prints your
127
+ workspace's issuer DID from the authenticated response so you never have to hunt for it).
128
+
129
+ One receipt proves itself. Pass several files and their **ordering** is verified too: the first
130
+ file is the anchor and each subsequent receipt must follow it, so verifying any consecutive
131
+ *segment* works — you are told whether the segment starts at your workspace's genesis or
132
+ mid-history, and a set spanning two different issuers is refused outright.
127
133
 
128
134
  What the credential contains, honestly: the sealed **authority decision** — who decided, what
129
135
  was authorized, when, under which policy. It seals at decision time and is immutable — which is
@@ -168,15 +174,36 @@ waiting: it parks its call arguments agent-side (`.decidio-pending/` by default
168
174
  stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
169
175
  exit. Two ways the parked action resumes when a human decides:
170
176
 
171
- **The worker (self-serve — start here).** One line in any long-lived process:
177
+ **The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
178
+ the current process never started. Here is a complete `agent.mjs` you can run:
172
179
 
173
180
  ```js
174
181
  import { guard } from "@decidio/sdk";
175
- guard.worker(); // polls Decidio outward, re-executes parked actions on approval, exactly once
182
+
183
+ const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id });
184
+
185
+ // Declare the action name so the wrapper registers at LOAD time, not on first call —
186
+ // that is what lets a restarted process resume actions approved while it was down.
187
+ const payInvoice = guard.protect(
188
+ payInvoiceRaw,
189
+ (invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
190
+ { action: "payInvoice" },
191
+ );
192
+
193
+ guard.worker(); // polls Decidio, re-executes parked actions on approval, one execution each
194
+
195
+ // New work suspends immediately; approvals land later, through the same worker.
196
+ try { await payInvoice({ id: "INV-1", amount: 86_000 }); }
197
+ catch (e) { console.log(e.name); } // DecidioSuspendedError — expected, not a failure
176
198
  ```
177
199
 
178
- No inbound URL, no shared secrets, survives restarts. This is the transport to use against the
179
- hosted sandbox.
200
+ Run it with `node --env-file=.env agent.mjs`, approve from another terminal, and the running
201
+ worker executes it. Kill the process, restart it, then approve: it still executes, because the
202
+ action was registered at load. **If you can't declare the name at wrap time** (a dynamic
203
+ `describe`), register the raw function explicitly at startup instead —
204
+ `guard.register("payInvoice", payInvoiceRaw)` — before calling `guard.worker()`. Without one of
205
+ those two, a cold worker has an empty registry and reports `no_handler` (the parked action is
206
+ kept, not lost, and the message names the exact fix).
180
207
 
181
208
  **The signed webhook (operator deployments).** Decidio POSTs a signed verdict to your agent's
182
209
  `resumeUrl`; the handler verifies the HMAC signature fail-closed, then re-runs your function:
@@ -198,6 +225,12 @@ your action succeeds but the process dies *before* the entry is cleared, the cla
198
225
  (robust crash-recovery of stale claims is on the roadmap), so make your downstream action
199
226
  idempotent if you need exactly-once under crashes.
200
227
 
228
+ **Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
229
+ disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
230
+ `undefined`, `NaN`, class instances with custom `toJSON` — come back changed and fail the
231
+ integrity check on resume (a refusal, never a wrong execution). Pass plain data; rehydrate
232
+ inside your function.
233
+
201
234
  **Payload integrity.** When a routed action is parked, the SDK signs its arguments (bound to the
202
235
  decision id) with your agent key; on resume it **refuses to execute** if the parked payload was
203
236
  modified, its signature stripped, or the file swapped into another decision's slot. That defends
@@ -227,9 +260,9 @@ imports none of them. Each adapter's shipped `.d.ts` header documents its wiring
227
260
  `npx @decidio/sdk <cmd>`:
228
261
 
229
262
  - `quickstart` — the guided two-minute tour (gate → approve → execute → proof)
230
- - `init [agentId] [--unique]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries)
263
+ - `init [agentId] [--unique] [--force] [--expires-days N]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries). Refuses to overwrite a *different* agent's key in this directory; `--force` backs the old `.env` up first.
231
264
  - `doctor` — config, connectivity, and what your current token can do
232
- - `approvals` / `approvals approve|reject <id> [reason]` — the human side, from your terminal
265
+ - `approvals [--mine] [--json]` / `approvals approve|reject <id> [reason]` — the human side, from your terminal (the list is workspace-wide; `--mine` narrows it to this directory's agent)
233
266
  - `receipt <id>` — download a sealed decision's Authority Receipt to a `.json` file
234
267
  - `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency)
235
268
  - `dev [--target URL]` — local relay for the resume webhook (operator setups)
package/dist/cli.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
5
  import { createInterface } from "node:readline";
6
6
  import { createRequire } from "node:module";
7
7
  import { generateKeypair } from "./signing.js";
8
8
  import { withApproval } from "./index.js";
9
- import { verifyReceipt, verifyChain } from "@decidio/verify";
9
+ import { verifyReceipt } from "@decidio/verify";
10
10
  const VERSION = (() => {
11
11
  try {
12
12
  return createRequire(import.meta.url)("../package.json").version;
@@ -66,14 +66,21 @@ async function api(path, init = {}, bearer) {
66
66
  }
67
67
  return { status: res.status, body };
68
68
  }
69
+ function writeSecretFile(path, body) {
70
+ writeFileSync(path, body, { mode: 0o600 });
71
+ try {
72
+ chmodSync(path, 0o600);
73
+ }
74
+ catch { }
75
+ }
69
76
  function upsertEnv(updates) {
70
77
  const cur = readEnvFile();
71
78
  const merged = { ...cur, ...updates };
72
79
  const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
73
- writeFileSync(ENV_PATH, body);
80
+ writeSecretFile(ENV_PATH, body);
74
81
  }
75
82
  function ensureGitignore() {
76
- const entries = [".env", ".env.quickstart", ".decidio-pending/"];
83
+ const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
77
84
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
78
85
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
79
86
  if (!missing.length)
@@ -95,13 +102,13 @@ function ask(question, mute = false) {
95
102
  });
96
103
  }
97
104
  async function acquireSession(purpose) {
98
- if (process.env.DECIDIO_SESSION_TOKEN)
99
- return process.env.DECIDIO_SESSION_TOKEN;
100
105
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
101
- log(`✘ refusing to send sign-in credentials over plaintext http to ${API}.`);
106
+ log(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
102
107
  log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
103
108
  return null;
104
109
  }
110
+ if (process.env.DECIDIO_SESSION_TOKEN)
111
+ return process.env.DECIDIO_SESSION_TOKEN;
105
112
  if (!process.stdin.isTTY) {
106
113
  log(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
107
114
  log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
@@ -138,10 +145,20 @@ async function acquireSession(purpose) {
138
145
  log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
139
146
  return null;
140
147
  }
141
- async function cmdInit(agentId) {
148
+ async function cmdInit(agentId, opts = {}) {
142
149
  log(`Registering agent "${agentId}" with Decidio at ${API}…`);
143
- const existingKey = readEnvFile().DECIDIO_AGENT_KEY;
144
- const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnvFile().DECIDIO_SOURCE_SYSTEM;
150
+ const existingEnv = readEnvFile();
151
+ const existingKey = existingEnv.DECIDIO_AGENT_KEY;
152
+ const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
153
+ if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
154
+ log(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
155
+ log(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
156
+ log(` overwritten is gone for good (it never left this machine). Nothing was written.`);
157
+ log(` Pick one:`);
158
+ log(` 1. New directory (cleanest): mkdir ${agentId} && cd ${agentId} && npx @decidio/sdk init ${agentId}`);
159
+ log(` 2. Replace the old agent here, backing its .env up first: npx @decidio/sdk init ${agentId} --force`);
160
+ return 1;
161
+ }
145
162
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
146
163
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
147
164
  let session = null;
@@ -183,11 +200,12 @@ async function cmdInit(agentId) {
183
200
  let agentToken, workspaceId;
184
201
  const mintBearer = session ?? TOKEN();
185
202
  if (mintBearer) {
186
- const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli" }) }, mintBearer);
203
+ const expiresInDays = opts.expiresDays ?? 90;
204
+ const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
187
205
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
188
206
  agentToken = mint.body.token;
189
207
  workspaceId = mint.body.workspaceId;
190
- log(`✔ minted the agent's API token — floor-limited: it can request approvals and nothing else${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""}`);
208
+ log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
191
209
  }
192
210
  else if (session) {
193
211
  log(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
@@ -210,11 +228,16 @@ async function cmdInit(agentId) {
210
228
  envOut.DECIDIO_API_TOKEN = agentToken;
211
229
  if (workspaceId)
212
230
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
231
+ ensureGitignore();
232
+ if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
233
+ const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
234
+ writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
235
+ log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
236
+ }
213
237
  upsertEnv(envOut);
214
238
  for (const [k, v] of Object.entries(envOut))
215
239
  process.env[k] = v;
216
- log(`✔ wrote ${ENV_PATH} — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
217
- ensureGitignore();
240
+ log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
218
241
  if (privateKey)
219
242
  log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
220
243
  log("");
@@ -258,6 +281,22 @@ async function cmdDoctor() {
258
281
  log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
259
282
  const h = await api("/api/health", {}, "");
260
283
  line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
284
+ const tok = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
285
+ if (tok) {
286
+ try {
287
+ const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
288
+ if (typeof payload?.exp === "number") {
289
+ const days = Math.floor((payload.exp * 1000 - Date.now()) / 86_400_000);
290
+ if (days < 0)
291
+ line(false, `agent token: EXPIRED ${-days} day(s) ago — re-run \`npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}\` to mint a fresh one.`);
292
+ else if (days <= 14)
293
+ log(`⚠ agent token: expires in ${days} day(s) — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
294
+ else
295
+ log(`✔ agent token: valid for another ${days} days`);
296
+ }
297
+ }
298
+ catch { }
299
+ }
261
300
  const rec = await api("/api/records");
262
301
  if (rec.status >= 200 && rec.status < 300)
263
302
  log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
@@ -275,9 +314,10 @@ async function cmdDoctor() {
275
314
  log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
276
315
  return ok ? 0 : 1;
277
316
  }
278
- async function cmdApprovals(sub, id, reason) {
279
- const jsonMode = sub === "--json";
280
- if (jsonMode)
317
+ async function cmdApprovals(sub, id, reason, flags = {}) {
318
+ const jsonMode = !!flags.json;
319
+ const mineOnly = !!flags.mine;
320
+ if (sub?.startsWith("--"))
281
321
  sub = undefined;
282
322
  let bearer;
283
323
  const probe = await api("/api/decisions/cards");
@@ -331,11 +371,16 @@ async function cmdApprovals(sub, id, reason) {
331
371
  return 0;
332
372
  }
333
373
  const myAgent = process.env.DECIDIO_AGENT_ID;
334
- log(`Pending agent approvals (${pending.length})${myAgent ? ` — workspace-wide; "←" marks this directory's agent (${myAgent})` : ""}:`);
335
- for (const c of pending) {
374
+ const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
375
+ const shown = mineOnly ? pending.filter(isMine) : pending;
376
+ if (mineOnly && !shown.length) {
377
+ log(`No pending approvals for "${myAgent}" (${pending.length} pending elsewhere in this workspace — drop --mine to see them).`);
378
+ return 0;
379
+ }
380
+ log(`Pending agent approvals (${shown.length}${mineOnly ? ` for "${myAgent}"` : ""})${!mineOnly && myAgent ? ` — workspace-wide; "←" marks this directory's agent (${myAgent}). Use --mine to list only those.` : ""}:`);
381
+ for (const c of shown) {
336
382
  const ar = c.agentRequest ?? {};
337
- const mine = myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
338
- log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}${mine ? " ←" : ""}`);
383
+ log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}${!mineOnly && isMine(c) ? " ←" : ""}`);
339
384
  }
340
385
  log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
341
386
  log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
@@ -381,16 +426,24 @@ async function cmdReceipt(id, jsonMode = false) {
381
426
  }
382
427
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
383
428
  const conf = detail.body?.record?.confirmation;
429
+ const issuer = r.body?.issuer;
384
430
  if (jsonMode) {
385
- log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed }, null, 2));
431
+ log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
386
432
  return 0;
387
433
  }
388
434
  if (conf?.status)
389
435
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
390
436
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
391
437
  log(` npx @decidio/sdk verify ${name}`);
392
- log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
393
- log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
438
+ if (issuer) {
439
+ log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
440
+ log(` ${issuer}`);
441
+ log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
442
+ }
443
+ else {
444
+ log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
445
+ log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
446
+ }
394
447
  return 0;
395
448
  }
396
449
  function parseReceiptStrict(text, file) {
@@ -440,6 +493,27 @@ function parseReceiptStrict(text, file) {
440
493
  }
441
494
  return value;
442
495
  }
496
+ function chainOf(vcs, expectedIssuer) {
497
+ if (!vcs.length)
498
+ return { valid: false, length: 0, reason: "no receipts supplied" };
499
+ const issuers = [...new Set(vcs.map((v) => String(v?.issuer ?? "")))];
500
+ if (issuers.length > 1)
501
+ return { valid: false, length: 0, reason: `these receipts come from ${issuers.length} DIFFERENT issuers — a chain must be issued end to end by one issuer` };
502
+ let prev = null;
503
+ let anchor = "segment";
504
+ for (let i = 0; i < vcs.length; i++) {
505
+ const r = verifyReceipt(vcs[i], expectedIssuer);
506
+ if (!r.valid)
507
+ return { valid: false, length: i, reason: `receipt ${i} (${r.recordId ?? "?"}) invalid: ${r.reason}` };
508
+ const cs = vcs[i]?.credentialSubject ?? {};
509
+ if (i === 0)
510
+ anchor = (cs.prevHash ?? null) === null ? "genesis" : "segment";
511
+ else if ((cs.prevHash ?? null) !== prev)
512
+ return { valid: false, length: i, reason: `receipt ${i} does not follow receipt ${i - 1} (prevHash mismatch)` };
513
+ prev = cs.contentHash;
514
+ }
515
+ return { valid: true, length: vcs.length, anchor };
516
+ }
443
517
  function cmdVerify(argsIn) {
444
518
  const issIdx = argsIn.indexOf("--issuer");
445
519
  if (issIdx >= 0 && (argsIn[issIdx + 1] === undefined || argsIn[issIdx + 1].startsWith("--"))) {
@@ -470,9 +544,9 @@ function cmdVerify(argsIn) {
470
544
  log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
471
545
  });
472
546
  if (vcs.length > 1) {
473
- const c = verifyChain(vcs, expectedIssuer);
547
+ const c = chainOf(vcs, expectedIssuer);
474
548
  allOk = allOk && c.valid;
475
- log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.reason ? ` — ${c.reason}` : ""}`);
549
+ log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.valid ? (c.anchor === "segment" ? " — segment: ordering proven from the FIRST file forward; history before it is not covered by these receipts" : " — from genesis") : ""}${c.reason ? ` — ${c.reason}` : ""}`);
476
550
  }
477
551
  return allOk ? 0 : 1;
478
552
  }
@@ -492,7 +566,7 @@ async function cmdQuickstart() {
492
566
  log(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
493
567
  return 1;
494
568
  }
495
- const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart" }) }, session);
569
+ const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
496
570
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
497
571
  log(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
498
572
  return 1;
@@ -570,8 +644,11 @@ async function cmdQuickstart() {
570
644
  }
571
645
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
572
646
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
573
- const v = verifyReceipt(rec.body.credential, undefined);
574
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline; issuer UNPINNED — bind it with: npx @decidio/sdk verify --issuer <your workspace DID>)`);
647
+ const issuer = rec.body?.issuer;
648
+ const v = verifyReceipt(rec.body.credential, issuer);
649
+ log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
650
+ if (issuer)
651
+ log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
575
652
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
576
653
  const tier = detail.body?.record?.confirmation?.status;
577
654
  if (tier)
@@ -625,9 +702,12 @@ const USAGE = `decidio — the Decidio SDK command line
625
702
 
626
703
  npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
627
704
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
628
- (--unique appends a random suffix — collision-proof samples)
705
+ --unique append a random suffix (collision-proof samples)
706
+ --force replace a different agent already in this .env
707
+ (its .env is backed up first)
708
+ --expires-days N agent-token lifetime (default 90)
629
709
  npx @decidio/sdk doctor check config, connectivity, and what your token can do
630
- npx @decidio/sdk approvals [--json] list pending agent approvals
710
+ npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
631
711
  npx @decidio/sdk approvals approve <id> [reason]
632
712
  npx @decidio/sdk approvals reject <id> [reason]
633
713
  npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
@@ -657,14 +737,24 @@ async function main(argv) {
657
737
  case "init": {
658
738
  const argsIn = argv.slice(3);
659
739
  const unique = argsIn.includes("--unique");
660
- const nameArg = argsIn.find((x) => !x.startsWith("-"));
740
+ const expIdx = argsIn.indexOf("--expires-days");
741
+ const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : undefined;
742
+ if (expIdx >= 0 && (!expRaw || !/^\d+$/.test(expRaw) || Number(expRaw) < 1)) {
743
+ log("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
744
+ return 2;
745
+ }
746
+ const nameArg = argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
661
747
  let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
662
748
  if (unique)
663
749
  name = `${name}-${randomBytes(2).toString("hex")}`;
664
- return cmdInit(name);
750
+ return cmdInit(name, { force: argsIn.includes("--force"), expiresDays: expRaw ? Number(expRaw) : undefined });
665
751
  }
666
752
  case "doctor": return cmdDoctor();
667
- case "approvals": return cmdApprovals(a, b, rest.join(" ") || undefined);
753
+ case "approvals": {
754
+ const argsIn = argv.slice(3);
755
+ const positional = argsIn.filter((x) => !x.startsWith("-"));
756
+ return cmdApprovals(positional[0], positional[1], positional.slice(2).join(" ") || undefined, { json: argsIn.includes("--json"), mine: argsIn.includes("--mine") });
757
+ }
668
758
  case "receipt": {
669
759
  const argsIn = argv.slice(3);
670
760
  return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
package/dist/guard.d.ts CHANGED
@@ -17,12 +17,14 @@ export interface GuardFacadeConfig {
17
17
  export interface ProtectOptions {
18
18
  adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
19
19
  mode?: "durable" | "blocking";
20
+ action?: string;
20
21
  }
21
22
  export declare class Guard {
22
23
  config: GuardFacadeConfig;
23
24
  resume?: ResumeController;
24
25
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
25
26
  static fromEnv(): Guard;
27
+ register(action: string, fn: (...a: any[]) => Promise<any>): void;
26
28
  resumeHandler(): import("http").RequestListener;
27
29
  resumeFetchHandler(): (req: Request) => Promise<Response>;
28
30
  worker(opts?: {
package/dist/guard.js CHANGED
@@ -22,6 +22,7 @@ export class Guard {
22
22
  appUrl: process.env.DECIDIO_APP_URL,
23
23
  });
24
24
  }
25
+ register(action, fn) { this.ensureResume().register(action, fn); }
25
26
  resumeHandler() { return this.ensureResume().webhookHandler(); }
26
27
  resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
27
28
  worker(opts) {
@@ -53,7 +54,10 @@ export class Guard {
53
54
  "Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
54
55
  return withApproval(fn, describe, base);
55
56
  }
56
- return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume: this.ensureResume() });
57
+ const resume = this.ensureResume();
58
+ if (opts?.action)
59
+ resume.register(opts.action, fn);
60
+ return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
57
61
  }
58
62
  }
59
63
  export const guard = Guard.fromEnv();
package/dist/resume.js CHANGED
@@ -57,7 +57,7 @@ export class FilePendingStore {
57
57
  key(id) { return id.replace(/[^\w.-]/g, "_"); }
58
58
  path(id) { return join(this.dir, `${this.key(id)}.json`); }
59
59
  claimedPath(id) { return join(this.dir, `${this.key(id)}.claimed`); }
60
- async put(p) { writeFileSync(this.path(p.decisionId), JSON.stringify(p, null, 2)); }
60
+ async put(p) { writeFileSync(this.path(p.decisionId), JSON.stringify(p, null, 2), { mode: 0o600 }); }
61
61
  async get(id) {
62
62
  for (const f of [this.path(id), this.claimedPath(id)])
63
63
  if (existsSync(f))
@@ -70,8 +70,16 @@ export class FilePendingStore {
70
70
  async list() {
71
71
  if (!existsSync(this.dir))
72
72
  return [];
73
- return readdirSync(this.dir).filter((f) => f.endsWith(".json"))
74
- .map((f) => JSON.parse(readFileSync(join(this.dir, f), "utf8")));
73
+ const out = [];
74
+ for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
75
+ try {
76
+ out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
77
+ }
78
+ catch (e) {
79
+ console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
80
+ }
81
+ }
82
+ return out;
75
83
  }
76
84
  async claim(id) {
77
85
  try {
@@ -143,7 +151,12 @@ export function createDecidioResume(opts) {
143
151
  workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
144
152
  webhookSecret: secret,
145
153
  store: opts.store,
146
- register(action, fn) { registry.set(action, fn); },
154
+ register(action, fn) {
155
+ const prev = registry.get(action);
156
+ if (prev && prev !== fn)
157
+ 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.`);
158
+ registry.set(action, fn);
159
+ },
147
160
  async park(p) {
148
161
  const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
149
162
  if (!argsSig)
@@ -187,19 +200,36 @@ export function createDecidioResume(opts) {
187
200
  if (integrity === "unbound")
188
201
  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.`);
189
202
  const fn = registry.get(pending.action);
190
- if (!fn)
191
- return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" — call resume.register(...) at startup` });
203
+ if (!fn) {
204
+ console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
205
+ console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
206
+ console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
207
+ console.error(` or register the raw function directly:`);
208
+ console.error(` guard.register("${pending.action}", doItRaw);`);
209
+ console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
210
+ 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` });
211
+ }
192
212
  const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
193
213
  if (!claimed)
194
214
  return { status: "unknown", decisionId };
195
215
  try {
196
216
  const result = await fn(...pending.args);
197
- await ctrl.store.delete(decisionId);
217
+ let confirmed = true;
198
218
  try {
199
219
  await confirm(ctrl, decisionId, result);
200
220
  }
201
221
  catch (e) {
202
- console.warn(`[decidio] resume confirm failed (action succeeded): ${e.message}`);
222
+ confirmed = false;
223
+ console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
224
+ }
225
+ const retainedIsSafe = typeof ctrl.store.claim === "function";
226
+ if (confirmed || !retainedIsSafe)
227
+ await ctrl.store.delete(decisionId);
228
+ if (!confirmed) {
229
+ if (retainedIsSafe)
230
+ 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.`);
231
+ else
232
+ 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.`);
203
233
  }
204
234
  return finish({ status: "executed", decisionId, action: pending.action, result });
205
235
  }
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.6
8
+ version: 0.1.7
9
9
  description: >
10
10
  Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
11
11
  receive the signed resume webhook when a routed action is decided. Every outcome seals a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",