@decidio/sdk 0.1.5 → 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/dist/cli.js CHANGED
@@ -1,31 +1,12 @@
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
- import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync } from "node:fs";
22
3
  import { randomBytes } from "node:crypto";
23
4
  import { createServer } from "node:http";
24
5
  import { createInterface } from "node:readline";
25
6
  import { createRequire } from "node:module";
26
7
  import { generateKeypair } from "./signing.js";
27
8
  import { withApproval } from "./index.js";
28
- import { verifyReceipt, verifyChain } from "@decidio/verify";
9
+ import { verifyReceipt } from "@decidio/verify";
29
10
  const VERSION = (() => {
30
11
  try {
31
12
  return createRequire(import.meta.url)("../package.json").version;
@@ -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;
@@ -96,16 +66,21 @@ async function api(path, init = {}, bearer) {
96
66
  }
97
67
  return { status: res.status, body };
98
68
  }
69
+ function writeSecretFile(path, body) {
70
+ writeFileSync(path, body, { mode: 0o600 });
71
+ try {
72
+ chmodSync(path, 0o600);
73
+ }
74
+ catch { }
75
+ }
99
76
  function upsertEnv(updates) {
100
77
  const cur = readEnvFile();
101
78
  const merged = { ...cur, ...updates };
102
79
  const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
103
- writeFileSync(ENV_PATH, body);
80
+ writeSecretFile(ENV_PATH, body);
104
81
  }
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
82
  function ensureGitignore() {
108
- const entries = [".env", ".decidio-pending/"];
83
+ const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
109
84
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
110
85
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
111
86
  if (!missing.length)
@@ -113,12 +88,10 @@ function ensureGitignore() {
113
88
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
114
89
  log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
115
90
  }
116
- // ---- interactive input (session sign-in) ---------------------------------------------------
117
91
  function ask(question, mute = false) {
118
92
  return new Promise((resolve) => {
119
93
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
120
94
  if (mute) {
121
- // Hide password echo: after the prompt renders, swallow subsequent output writes.
122
95
  const anyRl = rl;
123
96
  const write = anyRl._writeToOutput?.bind(rl);
124
97
  anyRl._writeToOutput = (s) => { if (s.includes(question))
@@ -128,19 +101,14 @@ function ask(question, mute = false) {
128
101
  process.stdout.write("\n"); resolve(answer.trim()); });
129
102
  });
130
103
  }
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
104
  async function acquireSession(purpose) {
135
- if (process.env.DECIDIO_SESSION_TOKEN)
136
- 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
105
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
140
- log(`✘ refusing to send sign-in credentials over plaintext http to ${API}.`);
106
+ log(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
141
107
  log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
142
108
  return null;
143
109
  }
110
+ if (process.env.DECIDIO_SESSION_TOKEN)
111
+ return process.env.DECIDIO_SESSION_TOKEN;
144
112
  if (!process.stdin.isTTY) {
145
113
  log(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
146
114
  log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
@@ -177,16 +145,22 @@ async function acquireSession(purpose) {
177
145
  log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
178
146
  return null;
179
147
  }
180
- async function cmdInit(agentId) {
148
+ async function cmdInit(agentId, opts = {}) {
181
149
  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
- const existingKey = readEnvFile().DECIDIO_AGENT_KEY;
185
- 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
+ }
186
162
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
187
163
  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
164
  let session = null;
191
165
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
192
166
  if (r.status === 401 || r.status === 403) {
@@ -197,10 +171,6 @@ async function cmdInit(agentId) {
197
171
  }
198
172
  let did, privateKey;
199
173
  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
174
  did = r.body?.did;
205
175
  if (existingKey) {
206
176
  log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it with the DECIDIO_AGENT_KEY already in .env.`);
@@ -227,18 +197,15 @@ async function cmdInit(agentId) {
227
197
  log(` Fix the cause above and re-run — init is safe to repeat.`);
228
198
  return 1;
229
199
  }
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
200
  let agentToken, workspaceId;
235
201
  const mintBearer = session ?? TOKEN();
236
202
  if (mintBearer) {
237
- 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);
238
205
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
239
206
  agentToken = mint.body.token;
240
207
  workspaceId = mint.body.workspaceId;
241
- 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)`}`);
242
209
  }
243
210
  else if (session) {
244
211
  log(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
@@ -256,19 +223,21 @@ async function cmdInit(agentId) {
256
223
  if (did)
257
224
  envOut.DECIDIO_AGENT_DID = did;
258
225
  if (privateKey)
259
- envOut.DECIDIO_AGENT_KEY = privateKey; // written locally; never left this host
226
+ envOut.DECIDIO_AGENT_KEY = privateKey;
260
227
  if (agentToken)
261
- envOut.DECIDIO_API_TOKEN = agentToken; // the FLOOR-LIMITED token — never the session
228
+ envOut.DECIDIO_API_TOKEN = agentToken;
262
229
  if (workspaceId)
263
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
+ }
264
237
  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
238
  for (const [k, v] of Object.entries(envOut))
269
239
  process.env[k] = v;
270
- log(`✔ wrote ${ENV_PATH} — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
271
- 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)`);
272
241
  if (privateKey)
273
242
  log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
274
243
  log("");
@@ -305,27 +274,33 @@ async function cmdDoctor() {
305
274
  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
275
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
307
276
  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");
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).
311
277
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
312
278
  if (signing)
313
279
  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
280
  else
315
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.");
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
282
  const h = await api("/api/health", {}, "");
320
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
+ }
321
300
  const rec = await api("/api/records");
322
301
  if (rec.status >= 200 && rec.status < 300)
323
302
  log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
324
303
  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
304
  const probe = await api("/agent/status/doctor-scope-probe");
330
305
  if (probe.status === 404)
331
306
  log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
@@ -339,11 +314,10 @@ async function cmdDoctor() {
339
314
  log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
340
315
  return ok ? 0 : 1;
341
316
  }
342
- 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
- const jsonMode = sub === "--json";
346
- if (jsonMode)
317
+ async function cmdApprovals(sub, id, reason, flags = {}) {
318
+ const jsonMode = !!flags.json;
319
+ const mineOnly = !!flags.mine;
320
+ if (sub?.startsWith("--"))
347
321
  sub = undefined;
348
322
  let bearer;
349
323
  const probe = await api("/api/decisions/cards");
@@ -360,9 +334,6 @@ async function cmdApprovals(sub, id, reason) {
360
334
  }
361
335
  const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
362
336
  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
337
  if (r.body?.alreadyResolved) {
367
338
  log(`• ${id} was already resolved — nothing changed.`);
368
339
  log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
@@ -392,7 +363,6 @@ async function cmdApprovals(sub, id, reason) {
392
363
  const cards = r.body?.cards ?? [];
393
364
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
394
365
  if (jsonMode) {
395
- // Stable machine output (round-3 ask): agents scripting the queue parse this, not prose.
396
366
  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
367
  return 0;
398
368
  }
@@ -401,11 +371,16 @@ async function cmdApprovals(sub, id, reason) {
401
371
  return 0;
402
372
  }
403
373
  const myAgent = process.env.DECIDIO_AGENT_ID;
404
- log(`Pending agent approvals (${pending.length})${myAgent ? ` — workspace-wide; "←" marks this directory's agent (${myAgent})` : ""}:`);
405
- 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) {
406
382
  const ar = c.agentRequest ?? {};
407
- const mine = myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
408
- 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) ? " ←" : ""}`);
409
384
  }
410
385
  log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
411
386
  log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
@@ -418,8 +393,6 @@ async function cmdReceipt(id, jsonMode = false) {
418
393
  log(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
419
394
  return 2;
420
395
  }
421
- // Receipts are workspace data — session scope, like approvals. (The record-detail /verify
422
- // route carries the credential; the plain record route does not — Codex finding.)
423
396
  let bearer;
424
397
  let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
425
398
  if (r.status === 401 || r.status === 403) {
@@ -439,32 +412,40 @@ async function cmdReceipt(id, jsonMode = false) {
439
412
  return 1;
440
413
  }
441
414
  const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
442
- writeFileSync(name, JSON.stringify(r.body.credential, null, 2) + "\n");
443
- log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
444
- // The credential seals at DECISION time (immutable), so execution evidence that arrived later
445
- // lives on the record — surface the CURRENT tier here so the reader doesn't need a second
446
- // lookup to see it (Codex catch).
415
+ const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
416
+ if (existsSync(name)) {
417
+ if (readFileSync(name, "utf8") !== nextBytes) {
418
+ log(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
419
+ return 1;
420
+ }
421
+ log(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
422
+ }
423
+ else {
424
+ writeFileSync(name, nextBytes);
425
+ log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
426
+ }
447
427
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
448
428
  const conf = detail.body?.record?.confirmation;
429
+ const issuer = r.body?.issuer;
449
430
  if (jsonMode) {
450
- 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));
451
432
  return 0;
452
433
  }
453
434
  if (conf?.status)
454
435
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
455
- // Emit EXACTLY the deterministic command we document (round-4 catch: the README and this hint
456
- // disagreed, and bare npx re-probes the registry) — the verifier ships INSIDE this package.
457
436
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
458
437
  log(` npx @decidio/sdk verify ${name}`);
459
- log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
460
- 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
+ }
461
447
  return 0;
462
448
  }
463
- // ---- verify (embedded) ----------------------------------------------------------------------
464
- /** Duplicate-key-refusing parse — the SAME semantics as the decidio-verify CLI (RFC 8785 §3.1:
465
- * duplicate keys make different parsers disagree on which value is real; refuse instead).
466
- * Conformance-locked: the cross-language runner asserts this door refuses the same vectors for
467
- * the same reasons as decidio-verify and python -m decidio.verify. */
468
449
  function parseReceiptStrict(text, file) {
469
450
  const value = JSON.parse(text);
470
451
  const stack = [];
@@ -512,8 +493,33 @@ function parseReceiptStrict(text, file) {
512
493
  }
513
494
  return value;
514
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
+ }
515
517
  function cmdVerify(argsIn) {
516
518
  const issIdx = argsIn.indexOf("--issuer");
519
+ if (issIdx >= 0 && (argsIn[issIdx + 1] === undefined || argsIn[issIdx + 1].startsWith("--"))) {
520
+ log("✘ --issuer needs a did:key value.");
521
+ return 2;
522
+ }
517
523
  const expectedIssuer = issIdx >= 0 ? argsIn[issIdx + 1] : undefined;
518
524
  const files = argsIn.filter((a, i) => !a.startsWith("--") && !(issIdx >= 0 && i === issIdx + 1));
519
525
  if (!files.length) {
@@ -538,13 +544,12 @@ function cmdVerify(argsIn) {
538
544
  log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
539
545
  });
540
546
  if (vcs.length > 1) {
541
- const c = verifyChain(vcs, expectedIssuer);
547
+ const c = chainOf(vcs, expectedIssuer);
542
548
  allOk = allOk && c.valid;
543
- 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}` : ""}`);
544
550
  }
545
551
  return allOk ? 0 : 1;
546
552
  }
547
- // ---- quickstart (the guided two-minute tour) ------------------------------------------------
548
553
  async function cmdQuickstart() {
549
554
  const t0 = Date.now();
550
555
  log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
@@ -553,9 +558,6 @@ async function cmdQuickstart() {
553
558
  const session = await acquireSession("The quickstart");
554
559
  if (!session)
555
560
  return 1;
556
- // 1. A collision-proof agent, registered fresh every run (round-4 ask: idempotent sample
557
- // registration). Key generated locally; floor-limited token minted; .env written so the
558
- // take-away snippet works in this directory afterward.
559
561
  const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
560
562
  log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
561
563
  const { did, privateKeyBase64 } = generateKeypair();
@@ -564,7 +566,7 @@ async function cmdQuickstart() {
564
566
  log(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
565
567
  return 1;
566
568
  }
567
- 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);
568
570
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
569
571
  log(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
570
572
  return 1;
@@ -574,9 +576,6 @@ async function cmdQuickstart() {
574
576
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
575
577
  DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "", DECIDIO_WEBHOOK_SECRET: readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex"),
576
578
  };
577
- // NEVER clobber a real agent's key (guard catch: keys never leave this machine — overwriting
578
- // .env's DECIDIO_AGENT_KEY orphans that agent irrecoverably). A directory that already holds
579
- // one gets a TOUR-SCOPED env file instead.
580
579
  let envFile = ENV_PATH;
581
580
  if (readEnvFile().DECIDIO_AGENT_KEY) {
582
581
  envFile = ".env.quickstart";
@@ -589,11 +588,11 @@ async function cmdQuickstart() {
589
588
  ensureGitignore();
590
589
  log(` ✔ key generated locally (public half registered) · floor-limited token minted · ${envFile} written`);
591
590
  log("");
592
- // 2. Gate a demo action — the same one line you'd wrap your real function with.
593
- log(`② Gating a demo action with ONE line — guard.protect(payInvoice, …):`);
591
+ log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
594
592
  log("");
595
593
  log(` const payInvoice = guard.protect(payInvoiceRaw,`);
596
- log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }));`);
594
+ log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }),`);
595
+ log(` { mode: "blocking" }); // your real agent typically omits this — durable + a worker`);
597
596
  log("");
598
597
  let decisionId = "";
599
598
  const cfg = {
@@ -615,7 +614,6 @@ async function cmdQuickstart() {
615
614
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
616
615
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
617
616
  log("");
618
- // 3. The human moment — approve right here, or from the Decidio app.
619
617
  if (process.stdin.isTTY) {
620
618
  await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
621
619
  }
@@ -628,7 +626,6 @@ async function cmdQuickstart() {
628
626
  return 1;
629
627
  }
630
628
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
631
- // 4. The blocking wrapper wakes and runs the agent's own function.
632
629
  let result;
633
630
  try {
634
631
  result = await running;
@@ -639,7 +636,6 @@ async function cmdQuickstart() {
639
636
  }
640
637
  log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
641
638
  log("");
642
- // 5. The proof: download the receipt and verify it offline, all in-process.
643
639
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
644
640
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
645
641
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
@@ -648,26 +644,28 @@ async function cmdQuickstart() {
648
644
  }
649
645
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
650
646
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
651
- const v = verifyReceipt(rec.body.credential, undefined);
652
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding verified with NO network, no Decidio account)`);
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>`);
653
652
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
654
653
  const tier = detail.body?.record?.confirmation?.status;
655
654
  if (tier)
656
- log(` ✔ execution evidence on the record: ${tier} — the wrapper's report was cryptographically bound to this agent`);
655
+ 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.)`);
657
656
  log("");
658
657
  const secs = Math.round((Date.now() - t0) / 1000);
659
- log(`Done in ${secs}s: gated → routed to a human → approved → executed once → sealed, verifiable proof in your hands.`);
658
+ log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
660
659
  log("");
661
660
  log("Take it to your real agent — the .env here already works:");
662
661
  log(` import { guard } from "@decidio/sdk";`);
663
662
  log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
663
+ log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
664
664
  log("");
665
- log("Next: the README's 7-step quickstart (durable mode, workers, adapters) · your queue at your Decidio app.");
665
+ log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
666
666
  return v.valid ? 0 : 1;
667
667
  }
668
668
  function cmdDev(target, port) {
669
- // Signal-only relay: receive Decidio's webhook here (a stable URL) and forward it to
670
- // your local agent's resume listener. Useful when the agent isn't directly reachable.
671
669
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
672
670
  createServer((req, res) => {
673
671
  if (req.method !== "POST") {
@@ -704,9 +702,12 @@ const USAGE = `decidio — the Decidio SDK command line
704
702
 
705
703
  npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
706
704
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
707
- (--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)
708
709
  npx @decidio/sdk doctor check config, connectivity, and what your token can do
709
- npx @decidio/sdk approvals [--json] list pending agent approvals
710
+ npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
710
711
  npx @decidio/sdk approvals approve <id> [reason]
711
712
  npx @decidio/sdk approvals reject <id> [reason]
712
713
  npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
@@ -736,14 +737,24 @@ async function main(argv) {
736
737
  case "init": {
737
738
  const argsIn = argv.slice(3);
738
739
  const unique = argsIn.includes("--unique");
739
- 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));
740
747
  let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
741
748
  if (unique)
742
749
  name = `${name}-${randomBytes(2).toString("hex")}`;
743
- return cmdInit(name);
750
+ return cmdInit(name, { force: argsIn.includes("--force"), expiresDays: expRaw ? Number(expRaw) : undefined });
744
751
  }
745
752
  case "doctor": return cmdDoctor();
746
- 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
+ }
747
758
  case "receipt": {
748
759
  const argsIn = argv.slice(3);
749
760
  return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
package/dist/guard.d.ts CHANGED
@@ -8,36 +8,25 @@ 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";
20
+ action?: string;
28
21
  }
29
22
  export declare class Guard {
30
23
  config: GuardFacadeConfig;
31
24
  resume?: ResumeController;
32
25
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
33
26
  static fromEnv(): Guard;
34
- /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
27
+ register(action: string, fn: (...a: any[]) => Promise<any>): void;
35
28
  resumeHandler(): import("http").RequestListener;
36
- /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
37
29
  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
30
  worker(opts?: {
42
31
  intervalMs?: number;
43
32
  timeoutMs?: number;
@@ -48,5 +37,4 @@ export declare class Guard {
48
37
  private ensureResume;
49
38
  protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
50
39
  }
51
- /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
52
40
  export declare const guard: Guard;