@decidio/sdk 0.1.4 → 0.1.6

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/cli.js CHANGED
@@ -1,23 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // @decidio/sdk CLI — the on-ramp. Run via `npx @decidio/sdk <cmd>` (or tsx in-repo).
3
- // init [agentId] sign in, register the agent, mint its API token, write .env
4
- // doctor check config + connectivity + durability readiness
5
- // approvals list pending agent approvals; `approvals approve|reject <id> [reason]`
6
- // receipt <id> download a sealed decision's Authority Receipt to a .json file
7
- // dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
8
- //
9
- // TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
10
- // workspace (init's register+mint, approvals, receipt) need the owner's SESSION token — acquired
11
- // interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
12
- // What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
13
- // needs (it can call /agent/* and nothing else). A session-capable secret must never live in
14
- // an agent host's .env — that was the pre-publish design's hole.
15
- //
16
- // MESSAGE DISCIPLINE (founder bar 2026-08-29, from the Codex acceptance run): every failure
17
- // tells the developer WHAT happened, WHY, and the EXACT next command. Every success says what
18
- // changed, where, and what to do next. No stack traces for expected failures; no green ✔ for a
19
- // no-op (the replay-approve bug: the server's idempotent alreadyResolved was reported as a
20
- // fresh success — an authority CLI may never dress a no-op as an action).
21
2
  import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
22
3
  import { randomBytes } from "node:crypto";
23
4
  import { createServer } from "node:http";
@@ -34,8 +15,6 @@ const VERSION = (() => {
34
15
  return "unknown";
35
16
  }
36
17
  })();
37
- // ---- environment: load the CWD .env FIRST (Codex finding: init writes .env, but the next
38
- // command ignored it and pointed at localhost). Process env always wins — .env only fills gaps.
39
18
  function readEnvFile() {
40
19
  const out = {};
41
20
  if (existsSync(".env"))
@@ -47,9 +26,6 @@ function readEnvFile() {
47
26
  return out;
48
27
  }
49
28
  for (const [k, v] of Object.entries(readEnvFile())) {
50
- // NEVER autoload the session token from disk: the whole design splits session (memory-only)
51
- // from the floor-limited agent token (.env). Silently honoring a parked session token would
52
- // reward exactly the credential-at-rest the split exists to prevent (guard catch 2026-08-29).
53
29
  if (k === "DECIDIO_SESSION_TOKEN") {
54
30
  console.log("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
55
31
  continue;
@@ -57,17 +33,11 @@ for (const [k, v] of Object.entries(readEnvFile())) {
57
33
  if (process.env[k] === undefined)
58
34
  process.env[k] = v;
59
35
  }
60
- // `quickstart` is the zero-config guided tour: with no DECIDIO_API_URL anywhere it targets the
61
- // hosted sandbox (and says so); every other command keeps the localhost dev default.
62
36
  const IS_QUICKSTART = process.argv[2] === "quickstart";
63
37
  const API = (process.env.DECIDIO_API_URL ?? (IS_QUICKSTART ? "https://decidio-api.onrender.com" : "http://localhost:4000")).replace(/\/$/, "");
64
- // Token is read AT CALL TIME, not captured at import: init mints a fresh agent token mid-run and
65
- // its closing doctor must probe with THAT, not the pre-init env (round-3 Codex catch — the stale
66
- // capture made init's own doctor print "token: REJECTED" seconds after a successful mint).
67
38
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
68
39
  const ENV_PATH = ".env";
69
40
  const log = (...a) => console.log(...a);
70
- /** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
71
41
  async function api(path, init = {}, bearer) {
72
42
  const auth = bearer ?? TOKEN();
73
43
  let res;
@@ -102,10 +72,8 @@ function upsertEnv(updates) {
102
72
  const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
103
73
  writeFileSync(ENV_PATH, body);
104
74
  }
105
- /** Keep secrets out of git without nagging: append the two entries to .gitignore when they're
106
- * not already covered. Creating the file in a non-git directory is harmless. */
107
75
  function ensureGitignore() {
108
- const entries = [".env", ".decidio-pending/"];
76
+ const entries = [".env", ".env.quickstart", ".decidio-pending/"];
109
77
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
110
78
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
111
79
  if (!missing.length)
@@ -113,12 +81,10 @@ function ensureGitignore() {
113
81
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
114
82
  log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
115
83
  }
116
- // ---- interactive input (session sign-in) ---------------------------------------------------
117
84
  function ask(question, mute = false) {
118
85
  return new Promise((resolve) => {
119
86
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
120
87
  if (mute) {
121
- // Hide password echo: after the prompt renders, swallow subsequent output writes.
122
88
  const anyRl = rl;
123
89
  const write = anyRl._writeToOutput?.bind(rl);
124
90
  anyRl._writeToOutput = (s) => { if (s.includes(question))
@@ -128,14 +94,9 @@ function ask(question, mute = false) {
128
94
  process.stdout.write("\n"); resolve(answer.trim()); });
129
95
  });
130
96
  }
131
- /** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
132
- * email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
133
- * password-less accounts the welcome flow creates). Never persisted. */
134
97
  async function acquireSession(purpose) {
135
98
  if (process.env.DECIDIO_SESSION_TOKEN)
136
99
  return process.env.DECIDIO_SESSION_TOKEN;
137
- // Same rule the server applies to resume URLs: credentials never ride plaintext http to a
138
- // non-loopback host. Refuse before prompting, not after collecting a password.
139
100
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
140
101
  log(`✘ refusing to send sign-in credentials over plaintext http to ${API}.`);
141
102
  log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
@@ -179,14 +140,10 @@ async function acquireSession(purpose) {
179
140
  }
180
141
  async function cmdInit(agentId) {
181
142
  log(`Registering agent "${agentId}" with Decidio at ${API}…`);
182
- // CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
183
- // did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
184
143
  const existingKey = readEnvFile().DECIDIO_AGENT_KEY;
185
144
  const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnvFile().DECIDIO_SOURCE_SYSTEM;
186
145
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
187
146
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
188
- // First try whatever bearer the env already carries (the local-dev / operator path — a static
189
- // admin token, or dev-open auth). On 401/403 fall through to the self-serve session flow.
190
147
  let session = null;
191
148
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
192
149
  if (r.status === 401 || r.status === 403) {
@@ -197,10 +154,6 @@ async function cmdInit(agentId) {
197
154
  }
198
155
  let did, privateKey;
199
156
  if (r.status === 409) {
200
- // Already registered to a server-held did. WITHOUT the original private key on this machine
201
- // this is a HARD STOP, not a degraded continue (round-3 Codex catch: init used to write a
202
- // keyless .env and print happy-path next steps — every request then routed unproven and
203
- // execution evidence silently capped at agent_asserted).
204
157
  did = r.body?.did;
205
158
  if (existingKey) {
206
159
  log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it with the DECIDIO_AGENT_KEY already in .env.`);
@@ -227,10 +180,6 @@ async function cmdInit(agentId) {
227
180
  log(` Fix the cause above and re-run — init is safe to repeat.`);
228
181
  return 1;
229
182
  }
230
- // Mint the runtime credential: the floor-limited AGENT token (can call /agent/* only). This is
231
- // what .env gets — the session token stays in memory and dies with this process. On boxes
232
- // without the tester-lab fence (plain local dev) the mint endpoint refuses; that's fine —
233
- // local dev auths with its existing env and we say so instead of failing the init.
234
183
  let agentToken, workspaceId;
235
184
  const mintBearer = session ?? TOKEN();
236
185
  if (mintBearer) {
@@ -256,15 +205,12 @@ async function cmdInit(agentId) {
256
205
  if (did)
257
206
  envOut.DECIDIO_AGENT_DID = did;
258
207
  if (privateKey)
259
- envOut.DECIDIO_AGENT_KEY = privateKey; // written locally; never left this host
208
+ envOut.DECIDIO_AGENT_KEY = privateKey;
260
209
  if (agentToken)
261
- envOut.DECIDIO_API_TOKEN = agentToken; // the FLOOR-LIMITED token — never the session
210
+ envOut.DECIDIO_API_TOKEN = agentToken;
262
211
  if (workspaceId)
263
212
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
264
213
  upsertEnv(envOut);
265
- // The closing doctor (and anything else this process does) must see what init just wrote —
266
- // OVERWRITE process.env with the fresh values (round-3 Codex catch: doctor probed with the
267
- // stale pre-init token and reported the mint it just watched as REJECTED).
268
214
  for (const [k, v] of Object.entries(envOut))
269
215
  process.env[k] = v;
270
216
  log(`✔ wrote ${ENV_PATH} — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
@@ -274,7 +220,7 @@ async function cmdInit(agentId) {
274
220
  log("");
275
221
  log("Next: protect one function and watch it route. Two steps:");
276
222
  log("");
277
- log(" 1. Save this as quickstart.mjs (the same snippet as the README's step 3):");
223
+ log(" 1. Save this as quickstart.mjs (the README's step-3 snippet — comments trimmed):");
278
224
  log("");
279
225
  log(` import { guard } from "@decidio/sdk";`);
280
226
  log("");
@@ -304,28 +250,18 @@ async function cmdDoctor() {
304
250
  line(!!(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL), `API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (unset — defaulting to localhost; the hosted sandbox is https://decidio-api.onrender.com)"}`);
305
251
  line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
306
252
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
307
- line(!!secret, secret ? "webhook secret: set (durable resume will fail-closed verify)" : "webhook secret: MISSING — the resume webhook will fail closed (init generates one)");
308
- // Signing key — REQUIRED to be ELIGIBLE for auto-approve. Whether anything auto-approves is
309
- // the workspace policy's call, not this key's (Codex finding: the old wording claimed
310
- // "auto-approve is in effect" while deny-by-default routed everything).
253
+ line(!!secret, secret ? "webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)" : "webhook secret: MISSING — the resume webhook (operator deployments) would fail closed; init generates one");
311
254
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
312
255
  if (signing)
313
256
  log("✔ signing key: set — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
314
257
  else
315
258
  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.");
316
- // Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
317
- // wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
318
- // /api/records then classifies the token.
319
259
  const h = await api("/api/health", {}, "");
320
260
  line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
321
261
  const rec = await api("/api/records");
322
262
  if (rec.status >= 200 && rec.status < 300)
323
263
  log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
324
264
  else if (rec.status === 401) {
325
- // A 401 off the agent floor could also mean an INVALID token (Codex catch: don't
326
- // false-green expiry). Disambiguate with a floor probe: a valid agent token gets 404 for an
327
- // unknown decision (authorized, not found); an invalid one gets 401 again. A rejection here
328
- // flips the overall verdict — "all green" after a red line was round 3's other doctor bug.
329
265
  const probe = await api("/agent/status/doctor-scope-probe");
330
266
  if (probe.status === 404)
331
267
  log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
@@ -340,8 +276,6 @@ async function cmdDoctor() {
340
276
  return ok ? 0 : 1;
341
277
  }
342
278
  async function cmdApprovals(sub, id, reason) {
343
- // Approvals are a HUMAN act on the workspace — session scope, never the agent token. If the
344
- // env's token is agent-floor (401 on /api/*), sign in interactively; memory-only as always.
345
279
  const jsonMode = sub === "--json";
346
280
  if (jsonMode)
347
281
  sub = undefined;
@@ -360,9 +294,6 @@ async function cmdApprovals(sub, id, reason) {
360
294
  }
361
295
  const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
362
296
  if (r.status >= 200 && r.status < 300) {
363
- // HONESTY over green checkmarks (Codex finding: the server resolves each decision exactly
364
- // once and answers replays idempotently — a replay is a NO-OP and must read as one, never
365
- // as a fresh approval). Non-zero exit so scripts can tell the difference.
366
297
  if (r.body?.alreadyResolved) {
367
298
  log(`• ${id} was already resolved — nothing changed.`);
368
299
  log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
@@ -392,7 +323,6 @@ async function cmdApprovals(sub, id, reason) {
392
323
  const cards = r.body?.cards ?? [];
393
324
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
394
325
  if (jsonMode) {
395
- // Stable machine output (round-3 ask): agents scripting the queue parse this, not prose.
396
326
  log(JSON.stringify(pending.map((c) => ({ decisionId: c.id, action: c.agentRequest?.action ?? null, amount: c.agentRequest?.amount ?? null, title: c.title ?? null })), null, 2));
397
327
  return 0;
398
328
  }
@@ -400,24 +330,24 @@ async function cmdApprovals(sub, id, reason) {
400
330
  log("No pending agent approvals. Trigger one from your protected function, then re-run this.");
401
331
  return 0;
402
332
  }
403
- log(`Pending agent approvals (${pending.length}):`);
333
+ const myAgent = process.env.DECIDIO_AGENT_ID;
334
+ log(`Pending agent approvals (${pending.length})${myAgent ? ` — workspace-wide; "←" marks this directory's agent (${myAgent})` : ""}:`);
404
335
  for (const c of pending) {
405
336
  const ar = c.agentRequest ?? {};
406
- log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}`);
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 ? " ←" : ""}`);
407
339
  }
408
340
  log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
409
341
  log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
410
342
  return 0;
411
343
  }
412
- async function cmdReceipt(id) {
344
+ async function cmdReceipt(id, jsonMode = false) {
413
345
  if (!id) {
414
346
  log("usage: npx @decidio/sdk receipt <decisionId>");
415
347
  log(" Downloads the decision's sealed Authority Receipt (a W3C Verifiable Credential) to a .json file.");
416
348
  log(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
417
349
  return 2;
418
350
  }
419
- // Receipts are workspace data — session scope, like approvals. (The record-detail /verify
420
- // route carries the credential; the plain record route does not — Codex finding.)
421
351
  let bearer;
422
352
  let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
423
353
  if (r.status === 401 || r.status === 403) {
@@ -437,28 +367,32 @@ async function cmdReceipt(id) {
437
367
  return 1;
438
368
  }
439
369
  const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
440
- writeFileSync(name, JSON.stringify(r.body.credential, null, 2) + "\n");
441
- log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
442
- // The credential seals at DECISION time (immutable), so execution evidence that arrived later
443
- // lives on the record — surface the CURRENT tier here so the reader doesn't need a second
444
- // lookup to see it (Codex catch).
370
+ const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
371
+ if (existsSync(name)) {
372
+ if (readFileSync(name, "utf8") !== nextBytes) {
373
+ log(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
374
+ return 1;
375
+ }
376
+ log(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
377
+ }
378
+ else {
379
+ writeFileSync(name, nextBytes);
380
+ log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
381
+ }
445
382
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
446
383
  const conf = detail.body?.record?.confirmation;
384
+ if (jsonMode) {
385
+ log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed }, null, 2));
386
+ return 0;
387
+ }
447
388
  if (conf?.status)
448
389
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
449
- // Emit EXACTLY the deterministic command we document (round-4 catch: the README and this hint
450
- // disagreed, and bare npx re-probes the registry) — the verifier ships INSIDE this package.
451
390
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
452
391
  log(` npx @decidio/sdk verify ${name}`);
453
392
  log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
454
393
  log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
455
394
  return 0;
456
395
  }
457
- // ---- verify (embedded) ----------------------------------------------------------------------
458
- /** Duplicate-key-refusing parse — the SAME semantics as the decidio-verify CLI (RFC 8785 §3.1:
459
- * duplicate keys make different parsers disagree on which value is real; refuse instead).
460
- * Conformance-locked: the cross-language runner asserts this door refuses the same vectors for
461
- * the same reasons as decidio-verify and python -m decidio.verify. */
462
396
  function parseReceiptStrict(text, file) {
463
397
  const value = JSON.parse(text);
464
398
  const stack = [];
@@ -508,6 +442,10 @@ function parseReceiptStrict(text, file) {
508
442
  }
509
443
  function cmdVerify(argsIn) {
510
444
  const issIdx = argsIn.indexOf("--issuer");
445
+ if (issIdx >= 0 && (argsIn[issIdx + 1] === undefined || argsIn[issIdx + 1].startsWith("--"))) {
446
+ log("✘ --issuer needs a did:key value.");
447
+ return 2;
448
+ }
511
449
  const expectedIssuer = issIdx >= 0 ? argsIn[issIdx + 1] : undefined;
512
450
  const files = argsIn.filter((a, i) => !a.startsWith("--") && !(issIdx >= 0 && i === issIdx + 1));
513
451
  if (!files.length) {
@@ -528,7 +466,7 @@ function cmdVerify(argsIn) {
528
466
  const res = verifyReceipt(vc, expectedIssuer);
529
467
  allOk = allOk && res.valid;
530
468
  log(`${res.valid ? "✔ VALID " : "✘ INVALID"} ${files[i]}`);
531
- log(` record: ${res.recordId ?? "?"} issuer: ${res.issuer}`);
469
+ log(` record: ${res.recordId ?? "?"} issuer: ${res.issuer}${expectedIssuer ? "" : " (unpinned — pass --issuer to bind to your workspace's DID)"}`);
532
470
  log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
533
471
  });
534
472
  if (vcs.length > 1) {
@@ -538,7 +476,6 @@ function cmdVerify(argsIn) {
538
476
  }
539
477
  return allOk ? 0 : 1;
540
478
  }
541
- // ---- quickstart (the guided two-minute tour) ------------------------------------------------
542
479
  async function cmdQuickstart() {
543
480
  const t0 = Date.now();
544
481
  log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
@@ -547,9 +484,6 @@ async function cmdQuickstart() {
547
484
  const session = await acquireSession("The quickstart");
548
485
  if (!session)
549
486
  return 1;
550
- // 1. A collision-proof agent, registered fresh every run (round-4 ask: idempotent sample
551
- // registration). Key generated locally; floor-limited token minted; .env written so the
552
- // take-away snippet works in this directory afterward.
553
487
  const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
554
488
  log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
555
489
  const { did, privateKeyBase64 } = generateKeypair();
@@ -568,9 +502,6 @@ async function cmdQuickstart() {
568
502
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
569
503
  DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "", DECIDIO_WEBHOOK_SECRET: readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex"),
570
504
  };
571
- // NEVER clobber a real agent's key (guard catch: keys never leave this machine — overwriting
572
- // .env's DECIDIO_AGENT_KEY orphans that agent irrecoverably). A directory that already holds
573
- // one gets a TOUR-SCOPED env file instead.
574
505
  let envFile = ENV_PATH;
575
506
  if (readEnvFile().DECIDIO_AGENT_KEY) {
576
507
  envFile = ".env.quickstart";
@@ -583,11 +514,11 @@ async function cmdQuickstart() {
583
514
  ensureGitignore();
584
515
  log(` ✔ key generated locally (public half registered) · floor-limited token minted · ${envFile} written`);
585
516
  log("");
586
- // 2. Gate a demo action — the same one line you'd wrap your real function with.
587
- log(`② Gating a demo action with ONE line — guard.protect(payInvoice, …):`);
517
+ log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
588
518
  log("");
589
519
  log(` const payInvoice = guard.protect(payInvoiceRaw,`);
590
- log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }));`);
520
+ log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }),`);
521
+ log(` { mode: "blocking" }); // your real agent typically omits this — durable + a worker`);
591
522
  log("");
592
523
  let decisionId = "";
593
524
  const cfg = {
@@ -609,7 +540,6 @@ async function cmdQuickstart() {
609
540
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
610
541
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
611
542
  log("");
612
- // 3. The human moment — approve right here, or from the Decidio app.
613
543
  if (process.stdin.isTTY) {
614
544
  await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
615
545
  }
@@ -622,7 +552,6 @@ async function cmdQuickstart() {
622
552
  return 1;
623
553
  }
624
554
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
625
- // 4. The blocking wrapper wakes and runs the agent's own function.
626
555
  let result;
627
556
  try {
628
557
  result = await running;
@@ -633,7 +562,6 @@ async function cmdQuickstart() {
633
562
  }
634
563
  log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
635
564
  log("");
636
- // 5. The proof: download the receipt and verify it offline, all in-process.
637
565
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
638
566
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
639
567
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
@@ -643,25 +571,24 @@ async function cmdQuickstart() {
643
571
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
644
572
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
645
573
  const v = verifyReceipt(rec.body.credential, undefined);
646
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding verified with NO network, no Decidio account)`);
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
575
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
648
576
  const tier = detail.body?.record?.confirmation?.status;
649
577
  if (tier)
650
- log(` ✔ execution evidence on the record: ${tier} — the wrapper's report was cryptographically bound to this agent`);
578
+ log(` ✔ execution evidence on the record: ${tier} — the wrapper's signed report. (The receipt seals the DECISION; execution evidence accrues on the record — see the README.)`);
651
579
  log("");
652
580
  const secs = Math.round((Date.now() - t0) / 1000);
653
- log(`Done in ${secs}s: gated → routed to a human → approved → executed once → sealed, verifiable proof in your hands.`);
581
+ log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
654
582
  log("");
655
583
  log("Take it to your real agent — the .env here already works:");
656
584
  log(` import { guard } from "@decidio/sdk";`);
657
585
  log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
586
+ log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
658
587
  log("");
659
- log("Next: the README's 7-step quickstart (durable mode, workers, adapters) · your queue at your Decidio app.");
588
+ log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
660
589
  return v.valid ? 0 : 1;
661
590
  }
662
591
  function cmdDev(target, port) {
663
- // Signal-only relay: receive Decidio's webhook here (a stable URL) and forward it to
664
- // your local agent's resume listener. Useful when the agent isn't directly reachable.
665
592
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
666
593
  createServer((req, res) => {
667
594
  if (req.method !== "POST") {
@@ -738,7 +665,10 @@ async function main(argv) {
738
665
  }
739
666
  case "doctor": return cmdDoctor();
740
667
  case "approvals": return cmdApprovals(a, b, rest.join(" ") || undefined);
741
- case "receipt": return cmdReceipt(a);
668
+ case "receipt": {
669
+ const argsIn = argv.slice(3);
670
+ return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
671
+ }
742
672
  case "verify": return cmdVerify(argv.slice(3));
743
673
  case "dev": {
744
674
  const args = argv.slice(3);
package/dist/guard.d.ts CHANGED
@@ -8,22 +8,14 @@ export interface GuardFacadeConfig {
8
8
  resumeUrl?: string;
9
9
  webhookSecret?: string;
10
10
  pendingDir?: string;
11
- /** Local signing key (DECIDIO_AGENT_KEY) + public did (DECIDIO_AGENT_DID), from `init`.
12
- * When present the gate authorizes against a PROVEN identity, not a claimed label. */
13
11
  agentKey?: string;
14
12
  agentDid?: string;
15
- /** The workspace this agent acts in (DECIDIO_WORKSPACE_ID). Bound into the signed proof (D-B3.6)
16
- * so a captured proof can't be replayed in another tenant. */
17
13
  workspaceId?: string;
18
- /** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
19
14
  appUrl?: string;
20
15
  fetchImpl?: typeof fetch;
21
16
  }
22
17
  export interface ProtectOptions {
23
- /** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest/mcp)
24
- * live in @decidio/sdk/adapters and take the engine handle. */
25
18
  adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
26
- /** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
27
19
  mode?: "durable" | "blocking";
28
20
  }
29
21
  export declare class Guard {
@@ -31,13 +23,8 @@ export declare class Guard {
31
23
  resume?: ResumeController;
32
24
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
33
25
  static fromEnv(): Guard;
34
- /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
35
26
  resumeHandler(): import("http").RequestListener;
36
- /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
37
27
  resumeFetchHandler(): (req: Request) => Promise<Response>;
38
- /** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
39
- * any parked approval — so a suspended action survives a process restart. One line at startup.
40
- * Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
41
28
  worker(opts?: {
42
29
  intervalMs?: number;
43
30
  timeoutMs?: number;
@@ -48,5 +35,4 @@ export declare class Guard {
48
35
  private ensureResume;
49
36
  protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
50
37
  }
51
- /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
52
38
  export declare const guard: Guard;
package/dist/guard.js CHANGED
@@ -1,7 +1,3 @@
1
- // The Guard facade — `guard.protect(fn, describe)`, the one agnostic line (TS mirror
2
- // of decidio-guard Python). A thin layer over the verified primitives: durable mode
3
- // builds a resume controller and suspends; blocking mode polls (opt-in, loud). The
4
- // agent executes its own action; Decidio gates + records + signals — never executes.
5
1
  import { withApproval } from "./index.js";
6
2
  import { createDecidioResume, FilePendingStore } from "./resume.js";
7
3
  export class Guard {
@@ -26,13 +22,8 @@ export class Guard {
26
22
  appUrl: process.env.DECIDIO_APP_URL,
27
23
  });
28
24
  }
29
- /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
30
25
  resumeHandler() { return this.ensureResume().webhookHandler(); }
31
- /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
32
26
  resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
33
- /** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
34
- * any parked approval — so a suspended action survives a process restart. One line at startup.
35
- * Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
36
27
  worker(opts) {
37
28
  return this.ensureResume().worker(opts);
38
29
  }
@@ -42,8 +33,6 @@ export class Guard {
42
33
  this.resume = createDecidioResume({
43
34
  apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
44
35
  resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
45
- // Carry the signing identity so the durable/cold confirm on resume is signed too (incl. the
46
- // workspace binding — D-B3.6 — so a cold-resume confirm proof is tenant-scoped as well).
47
36
  agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
48
37
  fetchImpl: c.fetchImpl,
49
38
  });
@@ -52,9 +41,7 @@ export class Guard {
52
41
  }
53
42
  protect(fn, describe, opts) {
54
43
  if (opts?.adapter && opts.adapter !== "generic") {
55
- // Engine adapters are bound explicitly via @decidio/sdk/adapters (they need the
56
- // engine handle at call time). Surfaced as a clear error until wired in this build.
57
- throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via @decidio/sdk/adapters/${opts.adapter} (it needs the engine handle).`);
44
+ throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via the @decidio/sdk/${opts.adapter} subpath (it needs the engine handle). See that adapter's exports.`);
58
45
  }
59
46
  const c = this.config;
60
47
  const base = {
@@ -64,13 +51,9 @@ export class Guard {
64
51
  if ((opts?.mode ?? "durable") === "blocking") {
65
52
  console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
66
53
  "Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
67
- // No resumeUrl on the blocking path — the server must not fire a webhook to an
68
- // endpoint a blocking agent isn't serving; the poll is the only resume channel.
69
54
  return withApproval(fn, describe, base);
70
55
  }
71
- // durable: refuse-don't-downgrade — always wire a resume transport, never silently block.
72
56
  return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume: this.ensureResume() });
73
57
  }
74
58
  }
75
- /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
76
59
  export const guard = Guard.fromEnv();
package/dist/index.d.ts CHANGED
@@ -6,49 +6,22 @@ export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
6
6
  export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
7
7
  export type { RequesterIdentity } from "./signing.js";
8
8
  export interface GuardConfig {
9
- /** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
10
9
  apiUrl: string;
11
- /** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
12
10
  agentId: string;
13
- /** Bearer token for the REST floor (a trusted, already-authenticated internal
14
- * caller). Lives ONLY in the agent host's environment — never in client code. */
15
11
  apiToken?: string;
16
- /** Source system label sealed onto the receipt (e.g. "salesforce"). */
17
12
  sourceSystem?: string;
18
- /** The agent's PRIVATE signing key (PKCS8 base64), generated locally by `init` and held
19
- * ONLY in the agent host's env as DECIDIO_AGENT_KEY. When set (with `agentDid`), the SDK
20
- * signs a request-bound identity proof on each authorize so the floor PROVES the requester
21
- * instead of trusting the label — required for a registered agent to auto-approve. */
22
13
  agentKey?: string;
23
- /** The agent's PUBLIC did:key (the one registered with Decidio). Pairs with `agentKey`. */
24
14
  agentDid?: string;
25
- /** The workspace this agent acts in (DECIDIO_WORKSPACE_ID, from the owner's agent-token mint).
26
- * Bound into the signed proof (D-B3.6) so a captured proof can't be replayed in another tenant.
27
- * For a tester's per-workspace agent token this is the tester's workspace id. */
28
15
  workspaceId?: string;
29
- /** Poll cadence + ceiling while a routed action awaits human approval. Used ONLY
30
- * by the blocking transport (no `resume` configured). */
31
16
  pollIntervalMs?: number;
32
17
  pollTimeoutMs?: number;
33
- /** Optional async notifier so a long human wait surfaces in your logs/UI. */
34
18
  onPending?: (info: {
35
19
  decisionId: string;
36
20
  receiptId: string;
37
21
  url: string | null;
38
22
  }) => void;
39
- /** Base URL of the Decidio app (DECIDIO_APP_URL). When set, a routed action can say WHERE to
40
- * approve it — `<appUrl>/d/<decisionId>` opens that decision directly, on web or on the phone
41
- * build. This link used to be built only inside our own example, so a developer who pasted the
42
- * env block set DECIDIO_APP_URL and nothing ever read it. */
43
23
  appUrl?: string;
44
- /** Durable async resume. When set, a routed action SUSPENDS (parks its args in the
45
- * store, throws DecidioSuspendedError) instead of blocking — the process may exit
46
- * and resume later via the controller's webhook handler or poll worker. This is
47
- * the production transport for real (minutes-to-days) human approvals. */
48
24
  resume?: ResumeController;
49
- /** Callback URL Decidio POSTs the verdict to on approval (webhook transport).
50
- * Sent on /agent/authorize so the decision carries it. Falls back to
51
- * resume.resumeUrl. Omit when using the poll worker. */
52
25
  resumeUrl?: string;
53
26
  fetchImpl?: typeof fetch;
54
27
  }
@@ -56,9 +29,6 @@ export interface ApprovalContext {
56
29
  action: string;
57
30
  amount: number;
58
31
  scope?: string;
59
- /** Optional human-facing DETAILS of the action (e.g. { Account, "Lead Source", Stage }) shown to
60
- * the approver as "what you're approving". Display-only — the policy decides on `amount`/`scope`,
61
- * never these. Residual-safe tokenized server-side before persistence. */
62
32
  context?: Record<string, string | number>;
63
33
  }
64
34
  export declare class DecidioBlockedError extends Error {
@@ -75,22 +45,11 @@ export declare class DecidioTimeoutError extends Error {
75
45
  decisionId: string;
76
46
  constructor(decisionId: string);
77
47
  }
78
- /** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
79
- * wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
80
- * endpoint, the operation, the underlying code, and the safety state a caller cares about most:
81
- * whether anything could have executed. */
82
48
  export declare class DecidioNetworkError extends Error {
83
49
  endpoint: string;
84
50
  op: string;
85
51
  code: string | undefined;
86
52
  constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
87
53
  }
88
- /**
89
- * Wrap an async action behind the Decidio gate. `describe` extracts the approval
90
- * context (action, amount, scope) from the call arguments. Returns a function with
91
- * the SAME signature as `fn` — drop-in.
92
- */
93
- /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
94
- * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
95
54
  export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
96
55
  export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig): (...args: A) => Promise<R>;