@decidio/sdk 0.1.10 → 0.1.12

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
@@ -6,8 +6,8 @@ import { createServer } from "node:http";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { createInterface } from "node:readline";
8
8
  import { createRequire } from "node:module";
9
- import { generateKeypair } from "./signing.js";
10
- import { withApproval, DecidioRateLimitError } from "./index.js";
9
+ import { generateKeypair, assertSafeTransport } from "./signing.js";
10
+ import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
11
11
  import { verifyReceipt, runVerify } from "@decidio/verify";
12
12
  const VERSION = (() => {
13
13
  try {
@@ -18,6 +18,7 @@ const VERSION = (() => {
18
18
  }
19
19
  })();
20
20
  let envWarned = false;
21
+ let envUnreadable = false;
21
22
  function readEnvFile() {
22
23
  const out = {};
23
24
  try {
@@ -29,9 +30,10 @@ function readEnvFile() {
29
30
  }
30
31
  }
31
32
  catch (e) {
33
+ envUnreadable = true;
32
34
  if (!envWarned) {
33
35
  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.`);
36
+ console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e}).`);
35
37
  }
36
38
  }
37
39
  return out;
@@ -58,10 +60,20 @@ async function api(path, init = {}, bearer) {
58
60
  err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
59
61
  }
60
62
  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
+ if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
64
+ err(`✘ .env exists here but could not be read, so the target Decidio is unknown — refusing to send credentials to a guessed host (${API}).`);
65
+ err(` Fix the file's permissions, or set DECIDIO_API_URL explicitly for this command. Nothing was sent.`);
63
66
  process.exit(1);
64
67
  }
68
+ if (auth) {
69
+ try {
70
+ assertSafeTransport(API, true);
71
+ }
72
+ catch (e) {
73
+ err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
74
+ process.exit(1);
75
+ }
76
+ }
65
77
  let res;
66
78
  try {
67
79
  res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
@@ -137,6 +149,28 @@ function upsertEnv(updates) {
137
149
  lines.push(`${k}=${v}`);
138
150
  writeSecretFile(ENV_PATH, lines.join("\n") + "\n");
139
151
  }
152
+ function isTrackedByGit(path) {
153
+ try {
154
+ execFileSync("git", ["ls-files", "--error-unmatch", path], { stdio: "ignore" });
155
+ return true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ function assertNotTracked(path) {
162
+ if (!isTrackedByGit(path))
163
+ return true;
164
+ err(`✘ ${path} is ALREADY TRACKED by git — refusing to write the agent's private key into it.`);
165
+ err(` A .gitignore entry does not untrack a file: the next \`git add -A\` would commit`);
166
+ err(` DECIDIO_AGENT_KEY and DECIDIO_API_TOKEN into permanent history. Nothing was written.`);
167
+ err(` Fix it, then re-run:`);
168
+ err(` git rm --cached ${path} # stop tracking it (keeps your local copy)`);
169
+ err(` git commit -m "stop tracking ${path}"`);
170
+ err(` If it has ALREADY been committed with secrets in it, rotate them — history rewriting`);
171
+ err(` alone does not un-leak a key that was pushed.`);
172
+ return false;
173
+ }
140
174
  function ensureGitignore() {
141
175
  const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
142
176
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
@@ -144,7 +178,7 @@ function ensureGitignore() {
144
178
  if (!missing.length)
145
179
  return;
146
180
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
147
- log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
181
+ log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
148
182
  }
149
183
  function ask(question, mute = false) {
150
184
  return new Promise((resolve) => {
@@ -160,7 +194,10 @@ function ask(question, mute = false) {
160
194
  });
161
195
  }
162
196
  async function acquireSession(purpose) {
163
- if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
197
+ try {
198
+ assertSafeTransport(API, true);
199
+ }
200
+ catch {
164
201
  err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
165
202
  err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
166
203
  return null;
@@ -204,6 +241,8 @@ async function acquireSession(purpose) {
204
241
  return null;
205
242
  }
206
243
  async function cmdInit(agentId, opts = {}) {
244
+ if (!assertNotTracked(ENV_PATH))
245
+ return 1;
207
246
  err(`Registering agent "${agentId}" with Decidio at ${API}…`);
208
247
  const existingEnv = readEnvFile();
209
248
  const existingKey = existingEnv.DECIDIO_AGENT_KEY;
@@ -342,8 +381,20 @@ async function cmdDoctor() {
342
381
  log(secret ? "✔ webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)"
343
382
  : "• webhook secret: not set — only needed for the operator resume-webhook transport; the poll worker guard.worker() is the self-serve resume path (init generates one)");
344
383
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
345
- if (signing)
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).");
384
+ if (signing) {
385
+ const storedDid = (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
386
+ let derived;
387
+ try {
388
+ derived = didFromPrivateKey((process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY));
389
+ }
390
+ catch { }
391
+ if (!derived)
392
+ line(false, "signing key: PRESENT BUT UNREADABLE — DECIDIO_AGENT_KEY is not a valid Ed25519 private key. Re-run `npx @decidio/sdk init` (its public half is what Decidio registered).");
393
+ else if (derived !== storedDid)
394
+ line(false, `signing key: DOES NOT MATCH this agent's DID — the key here derives ${derived}, but .env registers ${storedDid}. Signed requests will not be recognised as this agent (auto-approve and application_confirmed evidence will both fail). Restore the original key, or re-run \`npx @decidio/sdk init\` to register a fresh one.`);
395
+ else
396
+ log("✔ signing key: set and MATCHES the registered DID — 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).");
397
+ }
347
398
  else
348
399
  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.");
349
400
  const h = await api("/api/health", {}, "");
@@ -361,12 +412,13 @@ async function cmdDoctor() {
361
412
  const msLeft = payload.exp * 1000 - Date.now();
362
413
  const days = Math.floor((msLeft + 60_000) / 86_400_000);
363
414
  const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
415
+ const daysRounded = Math.round(msLeft / 86_400_000);
364
416
  if (msLeft < 0)
365
417
  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.`);
366
418
  else if (days <= 14)
367
419
  warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
368
420
  else
369
- log(`✔ agent token: valid for another ${days} days`);
421
+ log(`✔ agent token: unexpired for another ${daysRounded} days (validity is decided by the server — see the token-scope check below)`);
370
422
  }
371
423
  }
372
424
  catch { }
@@ -396,6 +448,11 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
396
448
  sub = undefined;
397
449
  if ((sub === "approve" || sub === "reject") && !id) {
398
450
  err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
451
+ err(` npx @decidio/sdk approvals ${sub} <decisionId> --reason "text with -dashes"`);
452
+ return 2;
453
+ }
454
+ if ((sub === "approve" || sub === "reject") && (flags.json || flags.mine)) {
455
+ err(`✘ ${flags.json ? "--json" : "--mine"} applies to \`approvals\` (the list), not to ${sub} — refusing to ignore a flag you meant to matter.`);
399
456
  return 2;
400
457
  }
401
458
  if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
@@ -422,8 +479,18 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
422
479
  return 1;
423
480
  }
424
481
  log(`✔ ${sub === "approve" ? "approved" : "rejected"} ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
425
- if (sub === "approve")
426
- log(` The agent's function runs next — a blocking call resumes now; a durable one when its worker or resume route picks this up.`);
482
+ if (reason) {
483
+ log(` reason sent verbatim: ${JSON.stringify(reason)}`);
484
+ log(` (it seals immutably; personal or sensitive values are tokenized at rest, so the`);
485
+ log(` stored text can differ — see exactly what was kept: npx @decidio/sdk receipt ${id})`);
486
+ }
487
+ if (sub === "approve") {
488
+ log(` What happens next depends on how the agent is waiting:`);
489
+ log(` • durable (the production mode): its worker or resume route picks this up and executes.`);
490
+ log(` • blocking, still running: it resumes now.`);
491
+ log(` • blocking, already timed out or exited: NOTHING executes — that process is gone, and this`);
492
+ log(` approval resolved the RECORD only. Re-run the action to request again.`);
493
+ }
427
494
  else
428
495
  log(` The agent's action will NOT run; the rejection seals as a verifiable record.`);
429
496
  log(` Download the receipt: npx @decidio/sdk receipt ${id}`);
@@ -517,11 +584,13 @@ async function cmdReceipt(id, jsonMode = false) {
517
584
  if (conf?.status)
518
585
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
519
586
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
520
- log(` npx @decidio/sdk verify ${name}`);
521
587
  if (issuer) {
522
- log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
523
- log(` ${issuer}`);
524
588
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
589
+ log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
590
+ log(` response above — record it once, then pin it forever):`);
591
+ log(` ${issuer}`);
592
+ log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
593
+ log(` issued it; it says so in its output.`);
525
594
  }
526
595
  else {
527
596
  log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
@@ -536,8 +605,8 @@ function cmdVerify(argsIn) {
536
605
  const a = argsIn[i];
537
606
  if (a === "--issuer") {
538
607
  const v = argsIn[i + 1];
539
- if (v === undefined || v.startsWith("-")) {
540
- err("✘ --issuer needs a did:key value.");
608
+ if (v === undefined || v === "" || v.startsWith("-")) {
609
+ err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
541
610
  return 2;
542
611
  }
543
612
  expectedIssuer = v;
@@ -587,12 +656,27 @@ async function cmdQuickstart(opts = {}) {
587
656
  const session = await acquireSession("The quickstart");
588
657
  if (!session)
589
658
  return 1;
590
- const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
591
- log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
659
+ let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
660
+ log(`① Registering agent "${agentId}"…`);
592
661
  const { did, privateKeyBase64 } = generateKeypair();
593
- const reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
662
+ let reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
663
+ for (let attempt = 0; reg.status === 409 && attempt < 3; attempt++) {
664
+ agentId = `quickstart-${randomBytes(5).toString("hex")}`;
665
+ reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
666
+ if (reg.status >= 200 && reg.status < 300)
667
+ log(` (that name was taken — using "${agentId}" instead)`);
668
+ }
594
669
  if (!(reg.status >= 200 && reg.status < 300)) {
595
- err(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
670
+ err(`✘ could not register the tour's agent (${reg.status}): ${JSON.stringify(reg.body)}`);
671
+ if (reg.status === 409) {
672
+ err(` Every generated name collided, which should be almost impossible — something is likely`);
673
+ err(` wrong with this workspace's agent list rather than with your setup.`);
674
+ err(` See what is registered: npx @decidio/sdk tokens list`);
675
+ }
676
+ else {
677
+ err(` Check the target and your sign-in, then re-run: npx @decidio/sdk doctor`);
678
+ }
679
+ err(` Nothing was written to this directory.`);
596
680
  return 1;
597
681
  }
598
682
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
@@ -609,6 +693,8 @@ async function cmdQuickstart(opts = {}) {
609
693
  let envFile = ENV_PATH;
610
694
  if (readEnvFile().DECIDIO_AGENT_KEY) {
611
695
  envFile = ".env.quickstart";
696
+ if (!assertNotTracked(envFile))
697
+ return 1;
612
698
  ensureGitignore();
613
699
  if (existsSync(envFile)) {
614
700
  const prev = readFileSync(envFile, "utf8");
@@ -623,6 +709,8 @@ async function cmdQuickstart(opts = {}) {
623
709
  log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
624
710
  }
625
711
  else {
712
+ if (!assertNotTracked(ENV_PATH))
713
+ return 1;
626
714
  ensureGitignore();
627
715
  upsertEnv(envOut);
628
716
  }
@@ -685,7 +773,10 @@ async function cmdQuickstart(opts = {}) {
685
773
  log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
686
774
  log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
687
775
  log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
688
- process.exit(0);
776
+ log("");
777
+ log(" (exit 3 = the tour stopped at the human moment. Nothing executed and no receipt was");
778
+ log(" written, so this run is deliberately not reported as a completed tour.)");
779
+ process.exit(3);
689
780
  }
690
781
  const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
691
782
  if (!(ap.status >= 200 && ap.status < 300)) {
@@ -713,7 +804,13 @@ async function cmdQuickstart(opts = {}) {
713
804
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
714
805
  const issuer = rec.body?.issuer;
715
806
  const v = verifyReceipt(rec.body.credential, issuer);
716
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
807
+ log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
808
+ if (!issuer) {
809
+ err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
810
+ err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
811
+ err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
812
+ err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
813
+ }
717
814
  if (issuer)
718
815
  log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
719
816
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
@@ -762,11 +859,20 @@ async function quickstartCleanup(session, envFile) {
762
859
  log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
763
860
  else if (rev.status >= 200 && rev.status < 300)
764
861
  log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
765
- else
766
- log(` ⚠ token revoke returned ${rev.status} — it still expires on its own within 7 days. List tokens: npx @decidio/sdk tokens list`);
862
+ else {
863
+ err(`✘ token revoke returned ${rev.status} — the tour's token is STILL LIVE (it would expire on its own within 7 days).`);
864
+ err(` Nothing was deleted: ${envFile} still holds the jti so you can retry. Run \`npx @decidio/sdk quickstart cleanup\` again, or revoke it directly:`);
865
+ err(` npx @decidio/sdk tokens revoke ${env.DECIDIO_TOKEN_JTI}`);
866
+ return 1;
867
+ }
767
868
  }
768
869
  else {
769
- log(` ⚠ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — revoke by hand: npx @decidio/sdk tokens list, then tokens revoke <jti>. It expires on its own within 7 days.`);
870
+ err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
871
+ err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
872
+ err(` npx @decidio/sdk tokens list`);
873
+ err(` npx @decidio/sdk tokens revoke <jti>`);
874
+ err(` (it also expires on its own within 7 days)`);
875
+ return 1;
770
876
  }
771
877
  if (existsSync(envFile)) {
772
878
  const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
@@ -916,6 +1022,12 @@ const USAGE = `decidio — the Decidio SDK command line
916
1022
  npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
917
1023
  npx @decidio/sdk approvals approve <id> [reason]
918
1024
  npx @decidio/sdk approvals reject <id> [reason]
1025
+ the reason is FREE TEXT, sent exactly as typed. It seals
1026
+ immutably; personal or sensitive values are tokenized at
1027
+ rest, so the STORED text can differ (see: receipt <id>).
1028
+ If it starts with a dash, put it after -- or use --reason:
1029
+ ... approve <id> -- "-50% cut, per the board"
1030
+ ... approve <id> --reason "-50% cut, per the board"
919
1031
  npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
920
1032
  npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
921
1033
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
@@ -924,7 +1036,9 @@ const USAGE = `decidio — the Decidio SDK command line
924
1036
  Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
925
1037
  DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
926
1038
  fully offline — it touches no network and reads no .env.
927
- Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
1039
+ Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
1040
+ 3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
1041
+ receipt written — pass --auto-approve-demo for a run that completes unattended).
928
1042
  Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
929
1043
  pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
930
1044
  Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
@@ -949,7 +1063,7 @@ async function main(argv) {
949
1063
  quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
950
1064
  init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
951
1065
  doctor: { flags: [] },
952
- approvals: { flags: ["--json", "--mine"] },
1066
+ approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
953
1067
  receipt: { flags: ["--json"] },
954
1068
  verify: { flags: [], valued: ["--issuer"] },
955
1069
  tokens: { flags: ["--mine"] },
@@ -961,6 +1075,17 @@ async function main(argv) {
961
1075
  const positionals = [];
962
1076
  for (let i = 0; i < argsIn.length; i++) {
963
1077
  const a = argsIn[i];
1078
+ if (a === "--") {
1079
+ positionals.push(...argsIn.slice(i + 1));
1080
+ break;
1081
+ }
1082
+ const inFreeTextTail = cmd === "approvals"
1083
+ && (positionals[0] === "approve" || positionals[0] === "reject")
1084
+ && positionals.length >= 2;
1085
+ if (!inFreeTextTail && a.startsWith("-") && !a.startsWith("--")) {
1086
+ err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored). Short flags are not supported; use the long form.\n\n${USAGE}`);
1087
+ return 2;
1088
+ }
964
1089
  if (!a.startsWith("--")) {
965
1090
  positionals.push(a);
966
1091
  continue;
@@ -1028,8 +1153,35 @@ async function main(argv) {
1028
1153
  }
1029
1154
  case "doctor": return cmdDoctor();
1030
1155
  case "approvals": {
1031
- const positional = argsIn.filter((x) => !x.startsWith("-"));
1032
- return cmdApprovals(positional[0], positional[1], positional.slice(2).join(" ") || undefined, { json: argsIn.includes("--json"), mine: argsIn.includes("--mine") });
1156
+ const dashdash = argsIn.indexOf("--");
1157
+ const head = dashdash >= 0 ? argsIn.slice(0, dashdash) : argsIn;
1158
+ const literal = dashdash >= 0 ? argsIn.slice(dashdash + 1) : [];
1159
+ const rIdx = head.indexOf("--reason");
1160
+ const rEq = head.find((x) => x.startsWith("--reason="));
1161
+ const flagless = head.filter((x, i) => !x.startsWith("-") && !(rIdx >= 0 && i === rIdx + 1));
1162
+ const sub = flagless[0];
1163
+ const id = flagless[1];
1164
+ let reason;
1165
+ if (rIdx >= 0)
1166
+ reason = head[rIdx + 1];
1167
+ else if (rEq !== undefined)
1168
+ reason = rEq.slice("--reason=".length);
1169
+ else if (literal.length)
1170
+ reason = literal.join(" ");
1171
+ else if (sub && id) {
1172
+ const after = head.indexOf(id) + 1;
1173
+ const rest = head.slice(after).filter((x) => x !== "--json" && x !== "--mine");
1174
+ reason = rest.length ? rest.join(" ") : undefined;
1175
+ }
1176
+ if ((rIdx >= 0 || rEq !== undefined) && (reason === undefined || reason === "" || (rIdx >= 0 && reason.startsWith("--")))) {
1177
+ err(`✘ --reason needs the text to record (quote it if it contains spaces): npx @decidio/sdk approvals ${sub ?? "approve"} <id> --reason "over budget -50% cut required"`);
1178
+ return 2;
1179
+ }
1180
+ if (reason !== undefined && sub !== "approve" && sub !== "reject") {
1181
+ err(`✘ --reason only applies to approve/reject (a sealed reason needs a decision to seal onto): npx @decidio/sdk approvals approve <id> --reason "..."`);
1182
+ return 2;
1183
+ }
1184
+ return cmdApprovals(sub, id, reason, { json: head.includes("--json"), mine: head.includes("--mine") });
1033
1185
  }
1034
1186
  case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
1035
1187
  case "verify": return cmdVerify(argsIn);
@@ -0,0 +1,12 @@
1
+ /** A gate response this SDK could not accept, carrying what a caller needs to BRANCH on rather
2
+ * than a stringified body they would have to parse. `status` is the HTTP status; `retryable`
3
+ * distinguishes a terminal refusal (401/403 — a credential problem no retry fixes) from a
4
+ * transient one (5xx, 429). Every long-running agent hits the terminal case eventually: an
5
+ * agent token expires, or `init` is re-run and revokes it. */
6
+ export declare class DecidioHttpError extends Error {
7
+ status: number;
8
+ endpoint: string;
9
+ body?: unknown | undefined;
10
+ readonly retryable: boolean;
11
+ constructor(status: number, endpoint: string, body?: unknown | undefined);
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,18 @@
1
+ export class DecidioHttpError extends Error {
2
+ status;
3
+ endpoint;
4
+ body;
5
+ retryable;
6
+ constructor(status, endpoint, body) {
7
+ const terminal = status === 401 || status === 403;
8
+ super(`[decidio] ${endpoint} refused this call (HTTP ${status})` +
9
+ (terminal
10
+ ? ` — the credential was rejected. The agent token may have expired or been revoked (re-running \`init\` for the same agent revokes the previous one). Mint a fresh one: npx @decidio/sdk init <agent-name>, then restart this process. Nothing was routed or executed.`
11
+ : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute). Nothing was routed or executed.`));
12
+ this.status = status;
13
+ this.endpoint = endpoint;
14
+ this.body = body;
15
+ this.name = "DecidioHttpError";
16
+ this.retryable = !terminal;
17
+ }
18
+ }
@@ -0,0 +1,45 @@
1
+ /** Every tier the gate can record, weakest to strongest. Mirrors the server's enum exactly; an
2
+ * answer outside this list means this SDK version does not understand the record, and an answer
3
+ * we do not understand is never treated as evidence. */
4
+ export declare const EVIDENCE_TIERS: readonly ["pending", "execution_unconfirmed", "agent_asserted", "sensor_observed", "application_confirmed", "independently_reconciled"];
5
+ export type EvidenceTier = (typeof EVIDENCE_TIERS)[number];
6
+ export interface EvidenceOutcome {
7
+ /** Decidio accepted the report and named the tier it recorded. */
8
+ reportRecorded: boolean;
9
+ /** The tier Decidio recorded, verbatim. */
10
+ evidenceTier?: EvidenceTier;
11
+ /** The recorded tier attests a committed execution. */
12
+ executionConfirmed: boolean;
13
+ /** Present when the outcome is worth explaining (why a signed report did not confirm, or why
14
+ * a report was not recorded at all) — always actionable, never a shrug. */
15
+ diagnostic?: string;
16
+ /** Present when the gate reported on the SIGNED execution report: whether the attestation
17
+ * proof VERIFIED (C3, 0.1.12 — this field used to be read off the wire and discarded, so a
18
+ * failed attestation was invisible to every client door). */
19
+ executionReportVerified?: boolean;
20
+ }
21
+ /** The SERVER's rule for `application_confirmed`, restated here exactly as implemented so this
22
+ * explanation can never drift from it: the captured response must carry a RECORD ID
23
+ * (`id` / `recordId` / `Id`), and must not explicitly deny success (`success: false` /
24
+ * `ok: false`). An id with no success field is treated as a commit; a response with neither is
25
+ * honestly `execution_unconfirmed`. */
26
+ export declare function describeTierGap(capturedResponse: unknown): string;
27
+ /** Read the gate's confirm response and say precisely what happened. Fail-closed: an unknown or
28
+ * missing tier is NOT a recorded report. */
29
+ export declare function readConfirmResponse(body: unknown, opts: {
30
+ signed: boolean;
31
+ capturedResponse?: unknown;
32
+ journalled?: boolean;
33
+ }): EvidenceOutcome;
34
+ import { type RequesterIdentity } from "./signing.js";
35
+ /** Minimal identity a confirm body needs. */
36
+ export interface ReportIdentity {
37
+ agentId: string;
38
+ agentKey?: string;
39
+ agentDid?: string;
40
+ workspaceId?: string;
41
+ }
42
+ /** The ONE confirm request shape, shared by every door (inline, durable resume, adapters).
43
+ * Carries the identity proof (who is reporting) AND — when a key is configured — a signed,
44
+ * decision-bound ExecutionReport over the canonical response digest (what was reported). */
45
+ export declare function buildConfirmBody(cfg: ReportIdentity, decisionId: string, canonicalResult: unknown, requesterIdentity?: RequesterIdentity, source?: string): Record<string, unknown>;
@@ -0,0 +1,65 @@
1
+ export const EVIDENCE_TIERS = [
2
+ "pending",
3
+ "execution_unconfirmed",
4
+ "agent_asserted",
5
+ "sensor_observed",
6
+ "application_confirmed",
7
+ "independently_reconciled",
8
+ ];
9
+ const CONFIRMING = ["sensor_observed", "application_confirmed", "independently_reconciled"];
10
+ export function describeTierGap(capturedResponse) {
11
+ const r = (capturedResponse ?? {});
12
+ const id = r.id ?? r.recordId ?? r.Id ?? null;
13
+ const denied = r.success === false || r.ok === false;
14
+ if (denied) {
15
+ return "your protected function reported success: false (or ok: false), so Decidio recorded the execution as UNCONFIRMED — that is the response saying the downstream write did not commit. If it did commit, return a truthy success flag.";
16
+ }
17
+ if (id == null) {
18
+ return "your protected function's return value carried no record id (`id`, `recordId` or `Id`), so Decidio has no evidence that the downstream write committed and honestly recorded the execution as UNCONFIRMED. Return the source system's identifiers (e.g. `{ id, success }` or `{ recordId, ok }`), or map them with the `evidence` option, to earn application_confirmed.";
19
+ }
20
+ return "the report was accepted but the gate recorded a weaker tier — check that this agent's registration and signing key still match (`npx @decidio/sdk doctor`).";
21
+ }
22
+ export function readConfirmResponse(body, opts) {
23
+ const retry = opts.journalled
24
+ ? "re-send the report with `guard.resume.reconfirm(decisionId)` once connectivity or the service is healthy."
25
+ : "this path journalled no report, so there is nothing to re-send from here — record the outcome from your own system, or run the action through `guard.protect`'s durable mode, where reports ARE journalled and `reconfirm` can replay them.";
26
+ const b = body;
27
+ if (!b || b.ok !== true || typeof b.confirmation !== "string") {
28
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate answered 2xx but named no evidence tier — the execution report is NOT recorded. Your action DID run; ${retry}` };
29
+ }
30
+ const tier = b.confirmation;
31
+ if (!EVIDENCE_TIERS.includes(tier)) {
32
+ return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate reported an evidence tier this SDK does not recognise (${JSON.stringify(tier)}) — refusing to treat it as evidence. Upgrade @decidio/sdk; your action DID run, and ${retry}` };
33
+ }
34
+ const executionConfirmed = CONFIRMING.includes(tier);
35
+ const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed };
36
+ if (!executionConfirmed && opts.signed)
37
+ outcome.diagnostic = describeTierGap(opts.capturedResponse);
38
+ else if (!executionConfirmed)
39
+ outcome.diagnostic = "no signing key is configured, so the gate capped this report at an unproven agent assertion. Run `npx @decidio/sdk init` to generate the agent's key and earn a confirming tier.";
40
+ const rpt = body?.executionReport;
41
+ if (rpt && typeof rpt === "object" && typeof rpt.verified === "boolean") {
42
+ outcome.executionReportVerified = rpt.verified;
43
+ if (!rpt.verified) {
44
+ const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
45
+ outcome.diagnostic = `${outcome.diagnostic ? outcome.diagnostic + " " : ""}The signed execution report did NOT verify${why} — the report was recorded without a proven attestation. Check the agent's key/DID/registration (npx @decidio/sdk doctor).`;
46
+ }
47
+ }
48
+ return outcome;
49
+ }
50
+ import { signExecutionReport, resultDigest } from "./signing.js";
51
+ export function buildConfirmBody(cfg, decisionId, canonicalResult, requesterIdentity, source = "wrapper") {
52
+ const executionReport = (cfg.agentKey && cfg.agentDid)
53
+ ? signExecutionReport(cfg.agentKey, {
54
+ decisionId, agentId: cfg.agentId, did: cfg.agentDid, workspaceId: cfg.workspaceId,
55
+ source, resultDigest: resultDigest(canonicalResult ?? null),
56
+ })
57
+ : undefined;
58
+ return {
59
+ decisionId,
60
+ capturedResponse: canonicalResult ?? null,
61
+ source,
62
+ ...(requesterIdentity ? { requesterIdentity } : {}),
63
+ ...(executionReport ? { executionReport } : {}),
64
+ };
65
+ }
package/dist/guard.d.ts CHANGED
@@ -21,7 +21,12 @@ export interface GuardFacadeConfig {
21
21
  }
22
22
  export interface ProtectOptions {
23
23
  /** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest)
24
- * live in @decidio/sdk subpaths and take the engine handle. */
24
+ * live in @decidio/sdk subpaths and take the engine handle.
25
+ *
26
+ * BETA: the engine adapters share the core's gate semantics (verdict allow-list, canonical
27
+ * arguments, transport rule and evidence handling are one implementation, conformance-tested
28
+ * across every door), but their ENGINE integration is verified against structural mocks, not
29
+ * live engines. Use the core path for production until that changes. */
25
30
  adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest";
26
31
  /** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
27
32
  mode?: "durable" | "blocking";
@@ -37,16 +42,30 @@ export interface ProtectOptions {
37
42
  pollTimeoutMs?: number;
38
43
  /** Blocking-mode poll interval in ms (default 3000). */
39
44
  pollIntervalMs?: number;
45
+ /** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
46
+ * its strongest wrapper tier (application_confirmed) only for a committed record id plus a
47
+ * success indication, so instead of forcing your API into magic field names:
48
+ * `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
49
+ evidence?: (result: any) => unknown;
40
50
  }
41
51
  export declare class Guard {
42
52
  config: GuardFacadeConfig;
43
- resume?: ResumeController;
53
+ private _resume?;
44
54
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
55
+ /** The recovery surface — `guard.resume.recover()` / `.reconfirm(id)`. BUILT ON FIRST ACCESS,
56
+ * because the process that needs to recover is usually a COLD one that never made the original
57
+ * call, so nothing has lazily constructed the controller yet. As a plain field this read was
58
+ * `undefined` there and the documented recovery loop crashed on the exact path it exists for.
59
+ * Unconfigured still fails loud (naming DECIDIO_API_URL), never silently. */
60
+ get resume(): ResumeController;
61
+ /** Kept writable: hosts that construct their own controller (custom store, injected fetch)
62
+ * assign it here, and `new Guard(cfg, controller)` must stay equivalent to that assignment. */
63
+ set resume(r: ResumeController);
45
64
  static fromEnv(): Guard;
46
65
  /** Register a raw fn under its action name so a COLD process (one that never made the original
47
66
  * call) can resume it. Call this at startup beside `guard.worker()` — or pass
48
67
  * `{ action }` to `protect(...)`, which does it for you at wrap time. */
49
- register(action: string, fn: (...a: any[]) => Promise<any>): void;
68
+ register(action: string, fn: (...a: any[]) => Promise<any>, evidence?: (result: any) => unknown): void;
50
69
  /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
51
70
  resumeHandler(): import("http").RequestListener;
52
71
  /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */