@decidio/sdk 0.1.8 → 0.1.10

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,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
3
- import { randomBytes, createHash } from "node:crypto";
3
+ import { randomBytes } from "node:crypto";
4
+ import { userInfo } from "node:os";
4
5
  import { createServer } from "node:http";
5
6
  import { execFileSync } from "node:child_process";
6
7
  import { createInterface } from "node:readline";
7
8
  import { createRequire } from "node:module";
8
9
  import { generateKeypair } from "./signing.js";
9
10
  import { withApproval, DecidioRateLimitError } from "./index.js";
10
- import { verifyReceipt } from "@decidio/verify";
11
+ import { verifyReceipt, runVerify } from "@decidio/verify";
11
12
  const VERSION = (() => {
12
13
  try {
13
14
  return createRequire(import.meta.url)("../package.json").version;
@@ -16,24 +17,35 @@ const VERSION = (() => {
16
17
  return "unknown";
17
18
  }
18
19
  })();
20
+ let envWarned = false;
19
21
  function readEnvFile() {
20
22
  const out = {};
21
- if (existsSync(".env"))
22
- for (const line of readFileSync(".env", "utf8").split(/\r?\n/)) {
23
- const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
24
- if (m)
25
- out[m[1]] = m[2];
23
+ try {
24
+ if (existsSync(".env"))
25
+ for (const line of readFileSync(".env", "utf8").split(/\r?\n/)) {
26
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
27
+ if (m)
28
+ out[m[1]] = m[2];
29
+ }
30
+ }
31
+ catch (e) {
32
+ if (!envWarned) {
33
+ envWarned = true;
34
+ console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e}) — continuing without it. Fix its permissions if you meant it to load.`);
26
35
  }
36
+ }
27
37
  return out;
28
38
  }
29
- for (const [k, v] of Object.entries(readEnvFile())) {
30
- if (k === "DECIDIO_SESSION_TOKEN") {
31
- console.error("⚠ 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.");
32
- continue;
39
+ const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
40
+ if (!OFFLINE_CMD)
41
+ for (const [k, v] of Object.entries(readEnvFile())) {
42
+ if (k === "DECIDIO_SESSION_TOKEN") {
43
+ console.error("⚠ 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.");
44
+ continue;
45
+ }
46
+ if (process.env[k] === undefined)
47
+ process.env[k] = v;
33
48
  }
34
- if (process.env[k] === undefined)
35
- process.env[k] = v;
36
- }
37
49
  const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
38
50
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
39
51
  const ENV_PATH = ".env";
@@ -46,6 +58,10 @@ async function api(path, init = {}, bearer) {
46
58
  err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
47
59
  }
48
60
  const auth = bearer ?? TOKEN();
61
+ if (auth && /^http:[/][/]/i.test(API) && !/^http:[/][/](localhost|127.0.0.1|.::1.)([:/]|$)/i.test(API)) {
62
+ err(`✘ refusing to send credentials over plaintext http to ${API} — use https, or localhost for local dev. Nothing was sent.`);
63
+ process.exit(1);
64
+ }
49
65
  let res;
50
66
  try {
51
67
  res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
@@ -80,18 +96,46 @@ function writeSecretFile(path, body) {
80
96
  catch { }
81
97
  if (process.platform === "win32") {
82
98
  try {
83
- execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${process.env.USERNAME ?? process.env.USER ?? ""}:F`], { stdio: "ignore" });
99
+ execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${effectiveUser()}:F`], { stdio: "ignore" });
84
100
  }
85
101
  catch {
86
102
  console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
87
103
  }
88
104
  }
89
105
  }
106
+ function effectiveUser() {
107
+ try {
108
+ return userInfo().username;
109
+ }
110
+ catch {
111
+ return process.env.USERNAME ?? process.env.USER ?? "";
112
+ }
113
+ }
90
114
  function upsertEnv(updates) {
91
- const cur = readEnvFile();
92
- const merged = { ...cur, ...updates };
93
- const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
94
- writeSecretFile(ENV_PATH, body);
115
+ const remaining = { ...updates };
116
+ const updatedKeys = new Set();
117
+ const lines = [];
118
+ try {
119
+ if (existsSync(ENV_PATH))
120
+ for (const line of readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
121
+ const m = line.match(/^\s*([A-Z_]+)=/);
122
+ if (m && updatedKeys.has(m[1]))
123
+ continue;
124
+ if (m && m[1] in remaining) {
125
+ lines.push(`${m[1]}=${remaining[m[1]]}`);
126
+ updatedKeys.add(m[1]);
127
+ delete remaining[m[1]];
128
+ }
129
+ else
130
+ lines.push(line);
131
+ }
132
+ }
133
+ catch { }
134
+ while (lines.length && lines[lines.length - 1] === "")
135
+ lines.pop();
136
+ for (const [k, v] of Object.entries(remaining))
137
+ lines.push(`${k}=${v}`);
138
+ writeSecretFile(ENV_PATH, lines.join("\n") + "\n");
95
139
  }
96
140
  function ensureGitignore() {
97
141
  const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
@@ -104,7 +148,7 @@ function ensureGitignore() {
104
148
  }
105
149
  function ask(question, mute = false) {
106
150
  return new Promise((resolve) => {
107
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
151
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
108
152
  if (mute) {
109
153
  const anyRl = rl;
110
154
  const write = anyRl._writeToOutput?.bind(rl);
@@ -112,23 +156,23 @@ function ask(question, mute = false) {
112
156
  write?.(s); };
113
157
  }
114
158
  rl.question(question, (answer) => { rl.close(); if (mute)
115
- process.stdout.write("\n"); resolve(answer.trim()); });
159
+ process.stderr.write("\n"); resolve(answer.trim()); });
116
160
  });
117
161
  }
118
162
  async function acquireSession(purpose) {
119
163
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
120
164
  err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
121
- log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
165
+ err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
122
166
  return null;
123
167
  }
124
168
  if (process.env.DECIDIO_SESSION_TOKEN)
125
169
  return process.env.DECIDIO_SESSION_TOKEN;
126
170
  if (!process.stdin.isTTY) {
127
171
  err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
128
- log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
129
- log(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
130
- log(` 2. POST its ?login= value — the response's "token" is your session token:`);
131
- log(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}'`);
172
+ err(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
173
+ err(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
174
+ err(` 2. POST its ?login= value — the response's "token" is your session token:`);
175
+ err(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}'`);
132
176
  return null;
133
177
  }
134
178
  log(`${purpose} needs your Decidio workspace sign-in (${API}).`);
@@ -160,17 +204,17 @@ async function acquireSession(purpose) {
160
204
  return null;
161
205
  }
162
206
  async function cmdInit(agentId, opts = {}) {
163
- log(`Registering agent "${agentId}" with Decidio at ${API}…`);
207
+ err(`Registering agent "${agentId}" with Decidio at ${API}…`);
164
208
  const existingEnv = readEnvFile();
165
209
  const existingKey = existingEnv.DECIDIO_AGENT_KEY;
166
210
  const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
167
211
  if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
168
212
  err(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
169
- log(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
170
- log(` overwritten is gone for good (it never left this machine). Nothing was written.`);
171
- log(` Pick one:`);
172
- log(` 1. New directory (cleanest): mkdir ${agentId} && cd ${agentId} && npx @decidio/sdk init ${agentId}`);
173
- log(` 2. Replace the old agent here, backing its .env up first: npx @decidio/sdk init ${agentId} --force`);
213
+ err(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
214
+ err(` overwritten is gone for good (it never left this machine). Nothing was written.`);
215
+ err(` Pick one:`);
216
+ err(` 1. New directory (cleanest): mkdir ${agentId} && cd ${agentId} && npx @decidio/sdk init ${agentId}`);
217
+ err(` 2. Replace the old agent here, backing its .env up first: npx @decidio/sdk init ${agentId} --force`);
174
218
  return 1;
175
219
  }
176
220
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
@@ -194,10 +238,10 @@ async function cmdInit(agentId, opts = {}) {
194
238
  else {
195
239
  const suggestion = `${agentId}-${randomBytes(2).toString("hex")}`;
196
240
  err(`✘ "${agentId}" is already registered in this workspace, and its private key is not on this machine`);
197
- log(` (keys never leave the machine that generated them — likely a previous run, or another directory).`);
198
- log(` Nothing was written. Two ways forward:`);
199
- log(` 1. Fresh agent (most common): npx @decidio/sdk init ${suggestion}`);
200
- log(` 2. Reuse "${agentId}": restore its original DECIDIO_AGENT_KEY into .env here, then re-run init.`);
241
+ err(` (keys never leave the machine that generated them — likely a previous run, or another directory).`);
242
+ err(` Nothing was written. Two ways forward:`);
243
+ err(` 1. Fresh agent (most common): npx @decidio/sdk init ${suggestion}`);
244
+ err(` 2. Reuse "${agentId}": restore its original DECIDIO_AGENT_KEY into .env here, then re-run init.`);
201
245
  return 1;
202
246
  }
203
247
  }
@@ -208,7 +252,7 @@ async function cmdInit(agentId, opts = {}) {
208
252
  }
209
253
  else {
210
254
  err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
211
- log(` Fix the cause above and re-run — init is safe to repeat.`);
255
+ err(` Fix the cause above and re-run — init is safe to repeat.`);
212
256
  return 1;
213
257
  }
214
258
  let agentToken, workspaceId, tokenJti;
@@ -221,10 +265,13 @@ async function cmdInit(agentId, opts = {}) {
221
265
  workspaceId = mint.body.workspaceId;
222
266
  tokenJti = mint.body.jti;
223
267
  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)`}`);
268
+ const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
269
+ for (const s of superseded)
270
+ log(` • this replaces the agent's previous token (jti ${s}) — now REVOKED: the server keeps one active token per agent. Anything still using it will 401.`);
224
271
  }
225
272
  else if (session) {
226
273
  err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
227
- log(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
274
+ err(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
228
275
  return 1;
229
276
  }
230
277
  else {
@@ -286,7 +333,9 @@ async function cmdInit(agentId, opts = {}) {
286
333
  async function cmdDoctor() {
287
334
  const env = readEnvFile();
288
335
  let ok = true;
336
+ let warned = 0;
289
337
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
338
+ const warn = (label) => { warned++; log(`⚠ ${label}`); };
290
339
  log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
291
340
  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>)"}`);
292
341
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
@@ -296,21 +345,26 @@ async function cmdDoctor() {
296
345
  if (signing)
297
346
  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).");
298
347
  else
299
- 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.");
348
+ warn("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.");
300
349
  const h = await api("/api/health", {}, "");
301
350
  line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
302
351
  const tok = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
352
+ if (!tok) {
353
+ line(false, "agent token: NOT CONFIGURED — no DECIDIO_API_TOKEN in this directory's .env or your environment. Run `npx @decidio/sdk init <agent-name>` to register an agent and mint one.");
354
+ log(warned ? `\ndoctor: fix the ✘ items above, then re-run \`npx @decidio/sdk doctor\`. (${warned} warning${warned === 1 ? "" : "s"})` : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
355
+ return 1;
356
+ }
303
357
  if (tok) {
304
358
  try {
305
359
  const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
306
360
  if (typeof payload?.exp === "number") {
307
361
  const msLeft = payload.exp * 1000 - Date.now();
308
- const days = Math.floor(msLeft / 86_400_000);
362
+ const days = Math.floor((msLeft + 60_000) / 86_400_000);
309
363
  const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
310
364
  if (msLeft < 0)
311
365
  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.`);
312
366
  else if (days <= 14)
313
- log(`⚠ agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
367
+ warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
314
368
  else
315
369
  log(`✔ agent token: valid for another ${days} days`);
316
370
  }
@@ -319,7 +373,7 @@ async function cmdDoctor() {
319
373
  }
320
374
  const rec = await api("/api/records");
321
375
  if (rec.status >= 200 && rec.status < 300)
322
- log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
376
+ warn("token scope: SESSION/ADMIN-capable — this credential can approve and administer, and it does not belong in an agent's environment (the agent runtime needs only the floor-limited token init mints). Move it to your shell/CI secret store.");
323
377
  else if (rec.status === 401) {
324
378
  const probe = await api("/agent/status/doctor-scope-probe");
325
379
  if (probe.status === 404)
@@ -327,11 +381,12 @@ async function cmdDoctor() {
327
381
  else if (probe.status === 401)
328
382
  line(false, "token: REJECTED on the agent floor too — it's invalid or expired. Re-run `npx @decidio/sdk init` to mint a fresh one.");
329
383
  else
330
- log(`⚠ token scope: agent-floor probe returned ${probe.status} — inconclusive`);
384
+ warn(`token scope: agent-floor probe returned ${probe.status} — inconclusive`);
331
385
  }
332
386
  else
333
- log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
334
- log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
387
+ warn(`token scope: unexpected ${rec.status} from /api/records`);
388
+ const warnNote = warned ? ` (${warned} warning${warned === 1 ? "" : "s"} above — working, worth a look)` : "";
389
+ log(ok ? `\ndoctor: all green.${warnNote}` : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
335
390
  return ok ? 0 : 1;
336
391
  }
337
392
  async function cmdApprovals(sub, id, reason, flags = {}) {
@@ -387,6 +442,8 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
387
442
  return 1;
388
443
  }
389
444
  const cards = r.body?.cards ?? [];
445
+ if (cards.length >= 50)
446
+ err('⚠ this view is capped at 50 items by the server — older pending approvals may exist beyond it.');
390
447
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
391
448
  const myAgent = process.env.DECIDIO_AGENT_ID;
392
449
  const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
@@ -472,85 +529,6 @@ async function cmdReceipt(id, jsonMode = false) {
472
529
  }
473
530
  return 0;
474
531
  }
475
- function parseReceiptStrict(text, file) {
476
- const value = JSON.parse(text);
477
- const stack = [];
478
- let i = 0;
479
- const readString = () => {
480
- let out = "";
481
- i++;
482
- while (i < text.length) {
483
- const ch = text[i];
484
- if (ch === "\\") {
485
- out += text[i] + text[i + 1];
486
- i += 2;
487
- continue;
488
- }
489
- if (ch === '"') {
490
- i++;
491
- return out;
492
- }
493
- out += ch;
494
- i++;
495
- }
496
- return out;
497
- };
498
- while (i < text.length) {
499
- const ch = text[i];
500
- if (ch === '"') {
501
- const s = readString();
502
- let j = i;
503
- while (j < text.length && /\s/.test(text[j]))
504
- j++;
505
- if (text[j] === ":" && stack.length > 0) {
506
- const key = JSON.parse(`"${s}"`);
507
- const top = stack[stack.length - 1];
508
- if (top.has(key))
509
- throw new Error(`${file}: duplicate JSON key ${JSON.stringify(key)} — RFC 8785 requires duplicate-free input, and different parsers disagree on which value is real`);
510
- top.add(key);
511
- }
512
- continue;
513
- }
514
- if (ch === "{")
515
- stack.push(new Set());
516
- else if (ch === "}")
517
- stack.pop();
518
- i++;
519
- }
520
- return value;
521
- }
522
- function chainOf(vcs, expectedIssuer) {
523
- if (!vcs.length)
524
- return { valid: false, length: 0, reason: "no receipts supplied" };
525
- const issuers = [...new Set(vcs.map((v) => String(v?.issuer ?? "")))];
526
- if (issuers.length > 1)
527
- 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` };
528
- let prev = null;
529
- let anchor = "segment";
530
- for (let i = 0; i < vcs.length; i++) {
531
- const r = verifyReceiptDoor(vcs[i], expectedIssuer);
532
- if (!r.valid)
533
- return { valid: false, length: i, reason: `receipt ${i} (${r.recordId ?? "?"}) invalid: ${r.reason}` };
534
- const cs = vcs[i]?.credentialSubject ?? {};
535
- if (i === 0)
536
- anchor = (cs.prevHash ?? null) === null ? "genesis" : "segment";
537
- else if ((cs.prevHash ?? null) !== prev)
538
- return { valid: false, length: i, reason: `receipt ${i} does not follow receipt ${i - 1} (prevHash mismatch)` };
539
- prev = cs.contentHash;
540
- }
541
- return { valid: true, length: vcs.length, anchor };
542
- }
543
- function verifyReceiptDoor(vc, expectedIssuer) {
544
- const res = verifyReceipt(vc, expectedIssuer);
545
- if (!res.valid && res.reason === "issuer DID is not the expected issuer") {
546
- const cs = vc?.credentialSubject ?? {};
547
- const derive = (canonical, prevHash) => createHash("sha256").update((prevHash ?? "") + canonical).digest("hex");
548
- const bound = typeof cs.recordCanonical === "string" && typeof cs.contentHash === "string"
549
- && derive(cs.recordCanonical, typeof cs.prevHash === "string" ? cs.prevHash : null) === cs.contentHash;
550
- return { ...res, contentBound: bound, recordId: typeof cs.recordId === "string" ? cs.recordId : res.recordId, reason: "issuer DID is not the expected issuer (the receipt itself may be internally consistent — it was just not issued by who you pinned)" };
551
- }
552
- return res;
553
- }
554
532
  function cmdVerify(argsIn) {
555
533
  let expectedIssuer;
556
534
  const files = [];
@@ -582,38 +560,20 @@ function cmdVerify(argsIn) {
582
560
  err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
583
561
  return 2;
584
562
  }
585
- const texts = [];
563
+ const texts = new Map();
586
564
  for (const f of files) {
587
565
  try {
588
- texts.push(readFileSync(f, "utf8"));
566
+ texts.set(f, readFileSync(f, "utf8"));
589
567
  }
590
568
  catch (e) {
591
569
  err(`✘ cannot read ${f}: ${e?.code === "ENOENT" ? "no such file" : e?.message ?? e} (nothing was verified)`);
592
570
  return 1;
593
571
  }
594
572
  }
595
- let vcs;
596
- try {
597
- vcs = files.map((f, i) => parseReceiptStrict(texts[i], f));
598
- }
599
- catch (e) {
600
- log(`✘ INVALID ${String(e?.message ?? e)}`);
601
- return 1;
602
- }
603
- let allOk = true;
604
- vcs.forEach((vc, i) => {
605
- const res = verifyReceiptDoor(vc, expectedIssuer);
606
- allOk = allOk && res.valid;
607
- log(`${res.valid ? "✔ VALID " : "✘ INVALID"} ${files[i]}`);
608
- log(` record: ${res.recordId ?? "?"} issuer: ${res.issuer}${expectedIssuer ? "" : " (unpinned — pass --issuer to bind to your workspace's DID)"}`);
609
- log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
610
- });
611
- if (vcs.length > 1) {
612
- const c = chainOf(vcs, expectedIssuer);
613
- allOk = allOk && c.valid;
614
- 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}` : ""}`);
615
- }
616
- return allOk ? 0 : 1;
573
+ const { ok, lines } = runVerify(files, expectedIssuer, (f) => texts.get(f));
574
+ for (const l of lines)
575
+ log(l);
576
+ return ok ? 0 : 1;
617
577
  }
618
578
  async function cmdQuickstart(opts = {}) {
619
579
  const t0 = Date.now();
@@ -654,8 +614,9 @@ async function cmdQuickstart(opts = {}) {
654
614
  const prev = readFileSync(envFile, "utf8");
655
615
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
656
616
  if (prevId && prevId !== agentId) {
657
- writeSecretFile(".env.quickstart.bak", prev);
658
- log(` ✔ previous tour agent (${prevId}) backed up to .env.quickstart.bak — its key would otherwise be unrecoverable`);
617
+ const bak = `.env.quickstart.${prevId.replace(/[^\w.-]/g, "_")}.bak`;
618
+ writeSecretFile(bak, prev);
619
+ log(` ✔ previous tour agent (${prevId}) backed up to ${bak} — its key would otherwise be unrecoverable (one backup PER agent: serial tours never clobber an earlier backup)`);
659
620
  }
660
621
  }
661
622
  writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
@@ -714,7 +675,10 @@ async function cmdQuickstart(opts = {}) {
714
675
  }
715
676
  else {
716
677
  log("③ This terminal can't put a human in the loop, and an approval IS a human act — the tour");
717
- log(" stops here rather than approve its own request. The decision is routed and waiting:");
678
+ log(" stops here rather than approve its own request. The decision is routed and waiting —");
679
+ log(" but note: this BLOCKING demo process is about to exit, so approving later resolves the");
680
+ log(" RECORD only; the demo function itself cannot run afterwards (durable mode is what");
681
+ log(" survives a process exit — the README's worker section shows it):");
718
682
  log("");
719
683
  log(` npx @decidio/sdk approvals approve ${decisionId} (or approve it in your Decidio app)`);
720
684
  log("");
@@ -794,7 +758,9 @@ async function quickstartCleanup(session, envFile) {
794
758
  })();
795
759
  if (env.DECIDIO_TOKEN_JTI) {
796
760
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
797
- if (rev.status >= 200 && rev.status < 300)
761
+ if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
762
+ log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
763
+ else if (rev.status >= 200 && rev.status < 300)
798
764
  log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
799
765
  else
800
766
  log(` ⚠ token revoke returned ${rev.status} — it still expires on its own within 7 days. List tokens: npx @decidio/sdk tokens list`);
@@ -830,9 +796,9 @@ async function quickstartCleanup(session, envFile) {
830
796
  log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
831
797
  return 0;
832
798
  }
833
- async function cmdTokens(sub, jti) {
799
+ async function cmdTokens(sub, jti, mineOnly = false) {
834
800
  if (sub !== "list" && sub !== "revoke") {
835
- err("usage: npx @decidio/sdk tokens list");
801
+ err("usage: npx @decidio/sdk tokens list [--mine]");
836
802
  err(" npx @decidio/sdk tokens revoke <jti>");
837
803
  err(" Agent tokens are shown by jti (their revocation id) — the token VALUE is never shown again after minting.");
838
804
  return 2;
@@ -841,6 +807,11 @@ async function cmdTokens(sub, jti) {
841
807
  err("usage: npx @decidio/sdk tokens revoke <jti> (find the jti with: npx @decidio/sdk tokens list)");
842
808
  return 2;
843
809
  }
810
+ if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
811
+ err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
812
+ err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
813
+ return 2;
814
+ }
844
815
  let bearer;
845
816
  let r = await api("/api/workspace/agent-token");
846
817
  if (r.status === 401 || r.status === 403) {
@@ -873,14 +844,16 @@ async function cmdTokens(sub, jti) {
873
844
  err(`✘ could not list tokens (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
874
845
  return 1;
875
846
  }
876
- const tokens = r.body?.tokens ?? r.body ?? [];
847
+ const all = r.body?.tokens ?? r.body ?? [];
848
+ const myAgent = process.env.DECIDIO_AGENT_ID;
849
+ const tokens = mineOnly ? all.filter((t) => t.agentId === myAgent) : all;
877
850
  if (!Array.isArray(tokens) || !tokens.length) {
878
- log("No agent tokens in this workspace.");
851
+ log(mineOnly ? `No agent tokens for "${myAgent}"${all.length ? ` (${all.length} elsewhere in this workspace — drop --mine to see them)` : ""}.` : "No agent tokens in this workspace.");
879
852
  return 0;
880
853
  }
881
854
  const status = (t) => t.revokedAt ? `REVOKED ${String(t.revokedAt).slice(0, 10)}`
882
855
  : (t.expiresAt && Date.parse(t.expiresAt) < Date.now()) ? "expired" : "active";
883
- log(`Agent tokens (${tokens.length}):`);
856
+ log(`Agent tokens (${tokens.length}${mineOnly ? ` for "${myAgent}"` : ""})${!mineOnly && myAgent ? ` — workspace-wide (a shared workspace shows every member's agents; --mine narrows to this directory's agent, ${myAgent})` : ""}:`);
884
857
  for (const t of tokens)
885
858
  log(` ${t.jti} ${status(t)} agent=${t.agentId ?? "?"} label=${t.label ?? ""} expires=${t.expiresAt ?? "?"}`);
886
859
  const al = r.body?.allowance;
@@ -921,16 +894,18 @@ const USAGE = `decidio — the Decidio SDK command line
921
894
  npx @decidio/sdk quickstart
922
895
 
923
896
  It gates a demo action, routes it to YOU for approval, executes on your yes, and hands you a
924
- cryptographically verifiable receipt. (Guided and explicit — this CLI never acts, signs you
925
- in, or writes files unless you ask it to.)
897
+ cryptographically verifiable receipt of the DECISION (execution evidence then accrues on the
898
+ hosted record — the receipt seals who authorized what, not what ran afterward). Guided and
899
+ explicit — this CLI never acts, signs you in, or writes files unless you ask it to.
926
900
 
927
901
  npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
928
902
  (interactive: YOUR Enter is the approval. Headless it stops
929
903
  at the routed decision; --auto-approve-demo for CI, labeled
930
904
  automated; ends with a keep-or-cleanup choice)
931
905
  npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
932
- npx @decidio/sdk tokens list agent tokens with status (active/expired/REVOKED) by jti —
933
- token values are never re-shown
906
+ npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
907
+ jti — token values are never re-shown; the list is
908
+ workspace-wide, --mine narrows to this directory's agent
934
909
  npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
935
910
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
936
911
  --unique append a random suffix (collision-proof samples)
@@ -946,11 +921,14 @@ const USAGE = `decidio — the Decidio SDK command line
946
921
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
947
922
  npx @decidio/sdk --version print the CLI version
948
923
 
949
- Every command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
950
- DECIDIO_API_URL says otherwise; the target host is announced on stderr each run.
924
+ Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
925
+ DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
926
+ fully offline — it touches no network and reads no .env.
951
927
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
952
- Errors and usage go to stderr; stdout carries results only (safe to pipe).
953
- Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
928
+ Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
929
+ pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
930
+ Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
931
+ (quickstart, durable resume, workers, adapters).`;
954
932
  async function main(argv) {
955
933
  const [cmd, a, b, ...rest] = argv.slice(2);
956
934
  const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
@@ -974,7 +952,7 @@ async function main(argv) {
974
952
  approvals: { flags: ["--json", "--mine"] },
975
953
  receipt: { flags: ["--json"] },
976
954
  verify: { flags: [], valued: ["--issuer"] },
977
- tokens: { flags: [] },
955
+ tokens: { flags: ["--mine"] },
978
956
  dev: { flags: [], valued: ["--target", "--port"] },
979
957
  };
980
958
  const spec = KNOWN[cmd];
@@ -1057,7 +1035,7 @@ async function main(argv) {
1057
1035
  case "verify": return cmdVerify(argsIn);
1058
1036
  case "tokens": {
1059
1037
  const positional = argsIn.filter((x) => !x.startsWith("-"));
1060
- return cmdTokens(positional[0], positional[1]);
1038
+ return cmdTokens(positional[0], positional[1], argsIn.includes("--mine"));
1061
1039
  }
1062
1040
  case "dev": {
1063
1041
  const flag = (name) => {
@@ -1080,7 +1058,10 @@ async function main(argv) {
1080
1058
  return cmdDev(target, Number(portRaw));
1081
1059
  }
1082
1060
  default:
1083
- err(`✘ unknown command "${cmd}"\n\n${USAGE}`);
1061
+ if (cmd.startsWith("-"))
1062
+ err(`✘ unknown command "${cmd}" — flags go AFTER the command (e.g. npx @decidio/sdk approvals ${cmd}).\n\n${USAGE}`);
1063
+ else
1064
+ err(`✘ unknown command "${cmd}"\n\n${USAGE}`);
1084
1065
  return 2;
1085
1066
  }
1086
1067
  }
package/dist/guard.d.ts CHANGED
@@ -8,25 +8,52 @@ 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. */
11
13
  agentKey?: string;
12
14
  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. */
13
17
  workspaceId?: string;
18
+ /** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
14
19
  appUrl?: string;
15
20
  fetchImpl?: typeof fetch;
16
21
  }
17
22
  export interface ProtectOptions {
18
- adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
23
+ /** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest)
24
+ * live in @decidio/sdk subpaths and take the engine handle. */
25
+ adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest";
26
+ /** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
19
27
  mode?: "durable" | "blocking";
28
+ /** The action name this wrapper produces, declared UP FRONT so the resume registry is populated
29
+ * at WRAP time rather than on first call. Required for the cold-worker pattern: after a
30
+ * restart, `guard.worker()` can only re-execute actions registered in THIS process, and
31
+ * `describe` (which names the action) cannot run without the original call arguments. Declare
32
+ * it and a restarted process resumes work it never originated. */
20
33
  action?: string;
34
+ /** Blocking-mode poll ceiling in ms (default 600000 = 10 min). Set it here so a CI pipeline
35
+ * fails fast instead of blocking the full 10-minute default while a routed action waits.
36
+ * Accepted identically by the Python SDK (poll_timeout, seconds). */
37
+ pollTimeoutMs?: number;
38
+ /** Blocking-mode poll interval in ms (default 3000). */
39
+ pollIntervalMs?: number;
21
40
  }
22
41
  export declare class Guard {
23
42
  config: GuardFacadeConfig;
24
43
  resume?: ResumeController;
25
44
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
26
45
  static fromEnv(): Guard;
46
+ /** Register a raw fn under its action name so a COLD process (one that never made the original
47
+ * call) can resume it. Call this at startup beside `guard.worker()` — or pass
48
+ * `{ action }` to `protect(...)`, which does it for you at wrap time. */
27
49
  register(action: string, fn: (...a: any[]) => Promise<any>): void;
50
+ /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
28
51
  resumeHandler(): import("http").RequestListener;
52
+ /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
29
53
  resumeFetchHandler(): (req: Request) => Promise<Response>;
54
+ /** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
55
+ * any parked approval — so a suspended action survives a process restart. One line at startup.
56
+ * Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
30
57
  worker(opts?: {
31
58
  intervalMs?: number;
32
59
  timeoutMs?: number;
@@ -35,6 +62,10 @@ export declare class Guard {
35
62
  stop: () => void;
36
63
  };
37
64
  private ensureResume;
65
+ /** Throws when the facade has no gate URL — at USE time, not import time, so `import { guard }`
66
+ * stays safe in envless contexts. */
67
+ private assertConfigured;
38
68
  protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
39
69
  }
70
+ /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
40
71
  export declare const guard: Guard;