@decidio/sdk 0.1.11 → 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/README.md CHANGED
@@ -413,6 +413,7 @@ manager for production.
413
413
  the fix. Anything else is transient, and retrying is safe because a retry creates a *new* request
414
414
  and can never double-execute.
415
415
  - `DecidioNetworkError` — the gate was unreachable. Nothing executed locally.
416
+ - `DecidioUnsafeArgumentsError` — your call arguments cannot survive JSON with their meaning intact, so nothing was routed and nothing ran. The message names the field and the fix. Most often: `NaN`/`Infinity`, `-0`, a `Date`, `Map` or class instance, a `BigInt`, or a sparse array — anything whose serialized form would differ from the value the approval and signature cover. Pass the already-serialized form.
416
417
 
417
418
  ```js
418
419
  try { await payInvoice(inv); }
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, canonicalize, readConfirmResponse, buildConfirmBody } from "../index.js";
2
+ import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody } from "../index.js";
3
3
  export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
4
4
  export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
5
5
  const a = auth;
@@ -7,11 +7,14 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
7
7
  if (!a || !AUTHORIZE_VERDICTS.includes(a.decision)) {
8
8
  throw new DecidioBlockedError(`unrecognised authorization verdict ${JSON.stringify(a?.decision ?? null)} — refusing to execute. The gate answers ${AUTHORIZE_VERDICTS.join(" | ")}; anything else means this SDK version does not understand the decision (upgrade @decidio/sdk), and an answer you do not understand is never permission.`, decisionId);
9
9
  }
10
+ if (typeof a.decisionId !== "string" || !a.decisionId) {
11
+ throw new DecidioBlockedError(`the gate answered "${a.decision}" with no usable decisionId (${JSON.stringify(a.decisionId ?? null)}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, decisionId);
12
+ }
10
13
  return a;
11
14
  }
12
15
  export async function gateAuthorize(config, ctx) {
13
16
  canonicalize(ctx, "describe(...)");
14
- assertSafeTransport(config.apiUrl, !!config.apiToken);
17
+ assertSafeTransport(config.apiUrl, carriesCredential(config));
15
18
  const f = config.fetchImpl ?? fetch;
16
19
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
17
20
  const requesterIdentity = (config.agentKey && config.agentDid)
@@ -40,17 +43,25 @@ export async function gateAuthorize(config, ctx) {
40
43
  export async function gateConfirm(config, decisionId, result) {
41
44
  const f = config.fetchImpl ?? fetch;
42
45
  try {
43
- assertSafeTransport(config.apiUrl, !!config.apiToken);
46
+ assertSafeTransport(config.apiUrl, carriesCredential(config));
44
47
  }
45
48
  catch (e) {
46
49
  console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
47
50
  return;
48
51
  }
52
+ let reported = null;
53
+ let evidenceProblem;
54
+ try {
55
+ reported = canonicalize(result ?? null, "result");
56
+ }
57
+ catch (e) {
58
+ evidenceProblem = String(e?.message ?? e);
59
+ console.warn(`[decidio] ${decisionId}: the action SUCCEEDED but its result could not be reported as evidence (${evidenceProblem}). Reporting an empty captured response so the execution is still recorded — map the return value with the \`evidence\` option to earn a confirming tier. The action was NOT re-run.`);
60
+ }
49
61
  try {
50
62
  const requesterIdentity = signConfirm(decisionId, config);
51
63
  if (!requesterIdentity)
52
64
  console.warn(`[decidio] execution evidence for ${decisionId} recorded as agent_asserted — no signing key configured, so the report can't be proven to come from this agent. Set DECIDIO_AGENT_KEY/DID to earn application_confirmed.`);
53
- const reported = canonicalize(result ?? null, "result");
54
65
  const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
55
66
  method: "POST",
56
67
  headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
@@ -63,8 +74,10 @@ export async function gateConfirm(config, decisionId, result) {
63
74
  const ev = readConfirmResponse(await res.json().catch(() => null), { signed: !!requesterIdentity, capturedResponse: reported });
64
75
  if (!ev.reportRecorded)
65
76
  console.warn(`[decidio] ${decisionId}: ${ev.diagnostic}`);
66
- else if (!ev.executionConfirmed)
77
+ else if (!ev.executionConfirmed || ev.executionReportVerified === false)
67
78
  console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
79
+ if (evidenceProblem)
80
+ console.warn(`[decidio] ${decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
68
81
  }
69
82
  catch (e) {
70
83
  console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
package/dist/canonical.js CHANGED
@@ -10,6 +10,22 @@ const NOT_ROUTED = "The decision was NOT routed; nothing reached Decidio.";
10
10
  function refuse(path, what, fix) {
11
11
  throw new DecidioUnsafeArgumentsError(`[decidio] ${path} ${what} — the approval payload must be plain JSON so that what a human approves is exactly what executes; ${fix} ${NOT_ROUTED}`);
12
12
  }
13
+ function assertNoLoneSurrogate(s, p) {
14
+ for (let i = 0; i < s.length; i++) {
15
+ const c = s.charCodeAt(i);
16
+ if (c >= 0xd800 && c <= 0xdbff) {
17
+ const n = s.charCodeAt(i + 1);
18
+ if (n >= 0xdc00 && n <= 0xdfff) {
19
+ i++;
20
+ continue;
21
+ }
22
+ refuse(p, `contains an unpaired surrogate (U+${c.toString(16).toUpperCase()})`, "it cannot be encoded as UTF-8, so no wire or seal can carry it — repair the string (it is usually a split emoji or truncated UTF-16).");
23
+ }
24
+ else if (c >= 0xdc00 && c <= 0xdfff) {
25
+ refuse(p, `contains an unpaired surrogate (U+${c.toString(16).toUpperCase()})`, "it cannot be encoded as UTF-8, so no wire or seal can carry it — repair the string (it is usually a split emoji or truncated UTF-16).");
26
+ }
27
+ }
28
+ }
13
29
  export function canonicalize(value, path = "args") {
14
30
  return walk(value, path, new Set());
15
31
  }
@@ -17,8 +33,12 @@ function walk(v, p, seen) {
17
33
  if (v === null)
18
34
  return null;
19
35
  const t = typeof v;
20
- if (t === "boolean" || t === "string")
36
+ if (t === "boolean")
37
+ return v;
38
+ if (t === "string") {
39
+ assertNoLoneSurrogate(v, p);
21
40
  return v;
41
+ }
22
42
  if (t === "number") {
23
43
  const n = v;
24
44
  if (!Number.isFinite(n))
@@ -72,7 +92,11 @@ function walk(v, p, seen) {
72
92
  refuse(`${p}.${key}`, "is a getter/setter", "an accessor is evaluated at serialization time, so the approved value could differ from the executed one — pass a plain data property.");
73
93
  if (!d.enumerable)
74
94
  refuse(`${p}.${key}`, "is a non-enumerable property", "JSON drops it, so the approver would never see it — make it enumerable or remove it.");
75
- out[key] = walk(d.value, `${p}.${key}`, seen);
95
+ assertNoLoneSurrogate(key, `${p}.${key}`);
96
+ Object.defineProperty(out, key, {
97
+ value: walk(d.value, `${p}.${key}`, seen),
98
+ writable: true, enumerable: true, configurable: true,
99
+ });
76
100
  }
77
101
  return out;
78
102
  }
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ 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";
9
+ import { generateKeypair, assertSafeTransport } from "./signing.js";
10
10
  import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
11
11
  import { verifyReceipt, runVerify } from "@decidio/verify";
12
12
  const VERSION = (() => {
@@ -65,9 +65,14 @@ async function api(path, init = {}, bearer) {
65
65
  err(` Fix the file's permissions, or set DECIDIO_API_URL explicitly for this command. Nothing was sent.`);
66
66
  process.exit(1);
67
67
  }
68
- if (auth && /^http:[/][/]/i.test(API) && !/^http:[/][/](localhost|127.0.0.1|.::1.)([:/]|$)/i.test(API)) {
69
- err(`✘ refusing to send credentials over plaintext http to ${API} — use https, or localhost for local dev. Nothing was sent.`);
70
- process.exit(1);
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
+ }
71
76
  }
72
77
  let res;
73
78
  try {
@@ -189,7 +194,10 @@ function ask(question, mute = false) {
189
194
  });
190
195
  }
191
196
  async function acquireSession(purpose) {
192
- if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
197
+ try {
198
+ assertSafeTransport(API, true);
199
+ }
200
+ catch {
193
201
  err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
194
202
  err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
195
203
  return null;
@@ -404,12 +412,13 @@ async function cmdDoctor() {
404
412
  const msLeft = payload.exp * 1000 - Date.now();
405
413
  const days = Math.floor((msLeft + 60_000) / 86_400_000);
406
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);
407
416
  if (msLeft < 0)
408
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.`);
409
418
  else if (days <= 14)
410
419
  warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
411
420
  else
412
- log(`✔ agent token: unexpired for another ${days} days (validity is decided by the server — see the token-scope check below)`);
421
+ log(`✔ agent token: unexpired for another ${daysRounded} days (validity is decided by the server — see the token-scope check below)`);
413
422
  }
414
423
  }
415
424
  catch { }
@@ -647,12 +656,27 @@ async function cmdQuickstart(opts = {}) {
647
656
  const session = await acquireSession("The quickstart");
648
657
  if (!session)
649
658
  return 1;
650
- const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
651
- log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
659
+ let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
660
+ log(`① Registering agent "${agentId}"…`);
652
661
  const { did, privateKeyBase64 } = generateKeypair();
653
- 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
+ }
654
669
  if (!(reg.status >= 200 && reg.status < 300)) {
655
- 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.`);
656
680
  return 1;
657
681
  }
658
682
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
@@ -749,7 +773,10 @@ async function cmdQuickstart(opts = {}) {
749
773
  log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
750
774
  log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
751
775
  log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
752
- 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);
753
780
  }
754
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);
755
782
  if (!(ap.status >= 200 && ap.status < 300)) {
@@ -777,7 +804,13 @@ async function cmdQuickstart(opts = {}) {
777
804
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
778
805
  const issuer = rec.body?.issuer;
779
806
  const v = verifyReceipt(rec.body.credential, issuer);
780
- 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
+ }
781
814
  if (issuer)
782
815
  log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
783
816
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
@@ -834,7 +867,12 @@ async function quickstartCleanup(session, envFile) {
834
867
  }
835
868
  }
836
869
  else {
837
- 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;
838
876
  }
839
877
  if (existsSync(envFile)) {
840
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"]);
@@ -984,8 +1022,10 @@ const USAGE = `decidio — the Decidio SDK command line
984
1022
  npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
985
1023
  npx @decidio/sdk approvals approve <id> [reason]
986
1024
  npx @decidio/sdk approvals reject <id> [reason]
987
- the reason is FREE TEXT and is sealed verbatim. If it
988
- starts with a dash, put it after -- or use --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:
989
1029
  ... approve <id> -- "-50% cut, per the board"
990
1030
  ... approve <id> --reason "-50% cut, per the board"
991
1031
  npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
@@ -996,7 +1036,9 @@ const USAGE = `decidio — the Decidio SDK command line
996
1036
  Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
997
1037
  DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
998
1038
  fully offline — it touches no network and reads no .env.
999
- 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).
1000
1042
  Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
1001
1043
  pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
1002
1044
  Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
@@ -1037,6 +1079,13 @@ async function main(argv) {
1037
1079
  positionals.push(...argsIn.slice(i + 1));
1038
1080
  break;
1039
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
+ }
1040
1089
  if (!a.startsWith("--")) {
1041
1090
  positionals.push(a);
1042
1091
  continue;
@@ -1108,12 +1157,15 @@ async function main(argv) {
1108
1157
  const head = dashdash >= 0 ? argsIn.slice(0, dashdash) : argsIn;
1109
1158
  const literal = dashdash >= 0 ? argsIn.slice(dashdash + 1) : [];
1110
1159
  const rIdx = head.indexOf("--reason");
1160
+ const rEq = head.find((x) => x.startsWith("--reason="));
1111
1161
  const flagless = head.filter((x, i) => !x.startsWith("-") && !(rIdx >= 0 && i === rIdx + 1));
1112
1162
  const sub = flagless[0];
1113
1163
  const id = flagless[1];
1114
1164
  let reason;
1115
1165
  if (rIdx >= 0)
1116
1166
  reason = head[rIdx + 1];
1167
+ else if (rEq !== undefined)
1168
+ reason = rEq.slice("--reason=".length);
1117
1169
  else if (literal.length)
1118
1170
  reason = literal.join(" ");
1119
1171
  else if (sub && id) {
@@ -1121,10 +1173,14 @@ async function main(argv) {
1121
1173
  const rest = head.slice(after).filter((x) => x !== "--json" && x !== "--mine");
1122
1174
  reason = rest.length ? rest.join(" ") : undefined;
1123
1175
  }
1124
- if (rIdx >= 0 && (reason === undefined || reason.startsWith("-"))) {
1176
+ if ((rIdx >= 0 || rEq !== undefined) && (reason === undefined || reason === "" || (rIdx >= 0 && reason.startsWith("--")))) {
1125
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"`);
1126
1178
  return 2;
1127
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
+ }
1128
1184
  return cmdApprovals(sub, id, reason, { json: head.includes("--json"), mine: head.includes("--mine") });
1129
1185
  }
1130
1186
  case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
@@ -13,6 +13,10 @@ export interface EvidenceOutcome {
13
13
  /** Present when the outcome is worth explaining (why a signed report did not confirm, or why
14
14
  * a report was not recorded at all) — always actionable, never a shrug. */
15
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;
16
20
  }
17
21
  /** The SERVER's rule for `application_confirmed`, restated here exactly as implemented so this
18
22
  * explanation can never drift from it: the captured response must carry a RECORD ID
package/dist/evidence.js CHANGED
@@ -37,6 +37,14 @@ export function readConfirmResponse(body, opts) {
37
37
  outcome.diagnostic = describeTierGap(opts.capturedResponse);
38
38
  else if (!executionConfirmed)
39
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
+ }
40
48
  return outcome;
41
49
  }
42
50
  import { signExecutionReport, resultDigest } from "./signing.js";
package/dist/guard.d.ts CHANGED
@@ -65,7 +65,7 @@ export declare class Guard {
65
65
  /** Register a raw fn under its action name so a COLD process (one that never made the original
66
66
  * call) can resume it. Call this at startup beside `guard.worker()` — or pass
67
67
  * `{ action }` to `protect(...)`, which does it for you at wrap time. */
68
- register(action: string, fn: (...a: any[]) => Promise<any>): void;
68
+ register(action: string, fn: (...a: any[]) => Promise<any>, evidence?: (result: any) => unknown): void;
69
69
  /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
70
70
  resumeHandler(): import("http").RequestListener;
71
71
  /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
package/dist/guard.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { withApproval } from "./index.js";
2
2
  import { createDecidioResume, FilePendingStore } from "./resume.js";
3
+ import { join } from "node:path";
3
4
  export class Guard {
4
5
  config;
5
6
  _resume;
@@ -24,7 +25,7 @@ export class Guard {
24
25
  appUrl: process.env.DECIDIO_APP_URL,
25
26
  });
26
27
  }
27
- register(action, fn) { this.ensureResume().register(action, fn); }
28
+ register(action, fn, evidence) { this.ensureResume().register(action, fn, evidence); }
28
29
  resumeHandler() { return this.ensureResume().webhookHandler(); }
29
30
  resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
30
31
  worker(opts) {
@@ -37,6 +38,7 @@ export class Guard {
37
38
  this._resume = createDecidioResume({
38
39
  apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
39
40
  resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
41
+ reportDir: join(c.pendingDir ?? ".decidio-pending", "reports"),
40
42
  agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
41
43
  fetchImpl: c.fetchImpl,
42
44
  });
@@ -72,9 +74,7 @@ export class Guard {
72
74
  }
73
75
  const resume = this.ensureResume();
74
76
  if (opts?.action)
75
- resume.register(opts.action, fn);
76
- if (opts?.evidence)
77
- resume.evidence = opts.evidence;
77
+ resume.register(opts.action, fn, opts?.evidence);
78
78
  return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, opts?.evidence ? { evidence: opts.evidence } : undefined);
79
79
  }
80
80
  }
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } fro
4
4
  export { guard, Guard } from "./guard.js";
5
5
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
6
6
  export { DecidioHttpError } from "./errors.js";
7
- export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
7
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential } from "./signing.js";
8
8
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
9
9
  export type { JsonValue, JsonPrimitive } from "./canonical.js";
10
10
  export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { DecidioSuspendedError } from "./resume.js";
2
- import { signBoundProof, signConfirm, assertSafeTransport } from "./signing.js";
2
+ import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential } from "./signing.js";
3
3
  import { canonicalize, canonicalizeArgs } from "./canonical.js";
4
4
  import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
5
5
  export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
6
6
  export { guard, Guard } from "./guard.js";
7
7
  export { DecidioHttpError } from "./errors.js";
8
8
  import { DecidioHttpError } from "./errors.js";
9
- export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
9
+ export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential } from "./signing.js";
10
10
  export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
11
11
  export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
12
12
  export class DecidioBlockedError extends Error {
@@ -80,7 +80,7 @@ async function reach(f, url, op, init) {
80
80
  }
81
81
  async function post(cfg, path, body) {
82
82
  const f = cfg.fetchImpl ?? fetch;
83
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
83
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
84
84
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
85
85
  method: "POST",
86
86
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
@@ -103,7 +103,7 @@ async function post(cfg, path, body) {
103
103
  }
104
104
  async function get(cfg, path) {
105
105
  const f = cfg.fetchImpl ?? fetch;
106
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
106
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
107
107
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${path}`, {
108
108
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
109
109
  });
@@ -151,6 +151,8 @@ export function withApproval(fn, describe, config, opts) {
151
151
  });
152
152
  if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
153
153
  throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
154
+ if (typeof auth.decisionId !== "string" || !auth.decisionId)
155
+ throw new DecidioBlockedError(`the gate answered "${auth.decision}" with no usable decisionId (${JSON.stringify(auth.decisionId ?? null)}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, auth.decisionId);
154
156
  if (auth.decision === "block")
155
157
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
156
158
  if (auth.decision === "route") {
@@ -163,7 +165,7 @@ export function withApproval(fn, describe, config, opts) {
163
165
  config.resume.agentId ??= config.agentId;
164
166
  config.resume.agentKey ??= config.agentKey;
165
167
  config.resume.agentDid ??= config.agentDid;
166
- config.resume.register(ctx.action, fn);
168
+ config.resume.register(ctx.action, fn, opts?.evidence);
167
169
  await config.resume.park({
168
170
  decisionId: auth.decisionId, receiptId: auth.receiptId,
169
171
  action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
@@ -187,16 +189,26 @@ export function withApproval(fn, describe, config, opts) {
187
189
  }
188
190
  }
189
191
  const result = await fn(...args);
192
+ let reported = null;
193
+ let evidenceProblem;
190
194
  try {
191
- const confirmIdentity = signConfirm(auth.decisionId, config);
192
195
  const evidenceOf = (opts?.evidence ?? ((r) => r));
193
- const reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
196
+ reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
197
+ }
198
+ catch (e) {
199
+ evidenceProblem = String(e?.message ?? e);
200
+ console.warn(`[decidio] ${auth.decisionId}: the action SUCCEEDED but its result could not be reported as evidence (${evidenceProblem}). Reporting an empty captured response so the execution is still recorded — map the return value with the \`evidence\` option (e.g. evidence: (r) => ({ recordId: String(r.id), success: true })) to earn a confirming tier. The action was NOT re-run.`);
201
+ }
202
+ try {
203
+ const confirmIdentity = signConfirm(auth.decisionId, config);
194
204
  const confirmBody = await post(config, "/agent/confirm", buildConfirmBody(config, auth.decisionId, reported, confirmIdentity));
195
205
  const ev = readConfirmResponse(confirmBody, { signed: !!confirmIdentity, capturedResponse: reported });
196
206
  if (!ev.reportRecorded)
197
207
  console.warn(`[decidio] ${auth.decisionId}: ${ev.diagnostic}`);
198
- else if (!ev.executionConfirmed)
208
+ else if (!ev.executionConfirmed || ev.executionReportVerified === false)
199
209
  console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
210
+ if (evidenceProblem)
211
+ console.warn(`[decidio] ${auth.decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
200
212
  }
201
213
  catch (e) {
202
214
  console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
package/dist/resume.d.ts CHANGED
@@ -142,6 +142,9 @@ export interface ResumeOutcome {
142
142
  executionConfirmed?: boolean;
143
143
  /** executed only: the evidence tier Decidio recorded, verbatim. */
144
144
  evidenceTier?: EvidenceTier;
145
+ /** executed only, when the gate reported on the SIGNED execution report: whether the
146
+ * attestation proof VERIFIED (C3, 0.1.12). */
147
+ executionReportVerified?: boolean;
145
148
  /** executed only: why the evidence landed where it did, when that is worth explaining. */
146
149
  diagnostic?: string;
147
150
  }
@@ -191,7 +194,7 @@ export interface ResumeController extends ResumeConfig {
191
194
  /** Register a RAW fn under its action name so a (possibly cold) resume can replay
192
195
  * it. `withApproval` auto-registers on the hot path; a cold worker/service should
193
196
  * register at startup so it can resume actions it never originated. */
194
- register(action: string, fn: (...args: any[]) => Promise<any>): void;
197
+ register(action: string, fn: (...args: any[]) => Promise<any>, evidence?: (result: any) => unknown): void;
195
198
  /** Park a routed action WITH its integrity binding (prefer this over store.put — the
196
199
  * binding is what lets resolve() refuse a tampered payload). */
197
200
  park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
package/dist/resume.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync
3
3
  import { join } from "node:path";
4
4
  import { userInfo } from "node:os";
5
5
  import { execFileSync } from "node:child_process";
6
- import { signConfirm, canonicalize as jcs, assertSafeTransport } from "./signing.js";
6
+ import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } from "./signing.js";
7
7
  import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
8
8
  import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
9
9
  import { DecidioHttpError } from "./errors.js";
@@ -172,9 +172,9 @@ function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
172
172
  console.warn(`[decidio] execution evidence for ${decisionId} will be recorded as an unproven agent assertion — no signing key configured. Set DECIDIO_AGENT_KEY/DID to earn a confirming tier.`);
173
173
  return buildConfirmBody({ agentId: cfg.agentId ?? "agent", agentKey: cfg.agentKey, agentDid: cfg.agentDid, workspaceId: cfg.workspaceId }, decisionId, canonicalResult, requesterIdentity);
174
174
  }
175
- async function postConfirm(cfg, decisionId, body) {
175
+ async function postConfirm(cfg, decisionId, body, journalled = true) {
176
176
  const f = cfg.fetchImpl ?? fetch;
177
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
177
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
178
178
  const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
179
179
  method: "POST",
180
180
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
@@ -190,11 +190,11 @@ async function postConfirm(cfg, decisionId, body) {
190
190
  throw new DecidioHttpError(r.status, "/agent/confirm", parsed);
191
191
  }
192
192
  const parsed = await r.json().catch(() => null);
193
- return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled: true });
193
+ return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled });
194
194
  }
195
195
  async function statusOf(cfg, decisionId) {
196
196
  const f = cfg.fetchImpl ?? fetch;
197
- assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
197
+ assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
198
198
  const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
199
199
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
200
200
  });
@@ -210,13 +210,21 @@ class ReportJournal {
210
210
  mkdirSync(dir, { recursive: true });
211
211
  hardenDir(dir);
212
212
  }
213
- catch { }
213
+ catch (e) {
214
+ console.error(`[decidio] could not create the execution-report journal directory ${dir} (${e?.message ?? e}) — reports will NOT be locally recoverable until this is fixed. Point it somewhere writable with reportDir (or DECIDIO_PENDING_DIR).`);
215
+ }
214
216
  }
215
217
  p(id) { return join(this.dir, `${id.replace(/[^\w.-]/g, "_")}.json`); }
216
- write(e) { try {
217
- writeFileSync(this.p(e.decisionId), JSON.stringify(e, null, 2), { mode: 0o600 });
218
+ write(e) {
219
+ try {
220
+ writeFileSync(this.p(e.decisionId), JSON.stringify(e, null, 2), { mode: 0o600 });
221
+ return true;
222
+ }
223
+ catch (err) {
224
+ console.error(`[decidio] ${e.decisionId}: the action RAN but its execution report could NOT be journalled to ${this.dir} (${err?.message ?? err}). If the report also fails to send, this execution is NOT locally recoverable — record the outcome from your own system. Fix the journal location with reportDir (or DECIDIO_PENDING_DIR).`);
225
+ return false;
226
+ }
218
227
  }
219
- catch { } }
220
228
  read(id) { try {
221
229
  return existsSync(this.p(id)) ? JSON.parse(readFileSync(this.p(id), "utf8")) : null;
222
230
  }
@@ -256,8 +264,9 @@ export function createDecidioResume(opts) {
256
264
  let reported;
257
265
  let haveReported = false;
258
266
  let buildProblem;
267
+ const evidenceMapper = registry.get(pending.action)?.evidence ?? ctrl.evidence;
259
268
  try {
260
- reported = toCanonical((ctrl.evidence ? ctrl.evidence(result) : result) ?? null, "evidence(result)");
269
+ reported = toCanonical((evidenceMapper ? evidenceMapper(result) : result) ?? null, "evidence(result)");
261
270
  haveReported = true;
262
271
  }
263
272
  catch (e) {
@@ -265,8 +274,7 @@ export function createDecidioResume(opts) {
265
274
  reported = null;
266
275
  haveReported = true;
267
276
  }
268
- if (haveReported)
269
- journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
277
+ const journalled = haveReported && journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
270
278
  let confirmRequest;
271
279
  try {
272
280
  confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
@@ -274,19 +282,32 @@ export function createDecidioResume(opts) {
274
282
  catch {
275
283
  confirmRequest = undefined;
276
284
  }
285
+ const deletePending = async () => {
286
+ try {
287
+ await ctrl.store.delete(decisionId);
288
+ }
289
+ catch (e) {
290
+ console.error(`[decidio] ${decisionId}: the action RAN but its pending entry could NOT be deleted (${e?.message ?? e}) — the entry SURVIVED. Remove it from the pending store by hand before another process or a restart can see it, or the committed action may be executed again.`);
291
+ }
292
+ };
277
293
  if (!confirmRequest) {
278
- await ctrl.store.delete(decisionId).catch(() => { });
294
+ await deletePending();
279
295
  const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it; record the outcome from your own system.`;
280
296
  console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
281
297
  return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
282
298
  }
283
- await ctrl.store.delete(decisionId).catch(() => { });
299
+ await deletePending();
284
300
  let ev;
285
301
  try {
286
- ev = await postConfirm(ctrl, decisionId, confirmRequest);
302
+ ev = await postConfirm(ctrl, decisionId, confirmRequest, journalled);
287
303
  }
288
304
  catch (e) {
289
- ev = { reportRecorded: false, executionConfirmed: false, diagnostic: `the execution report did not reach Decidio (${e?.message ?? e}). Your action DID run and the report is journalled - re-send it with reconfirm("${decisionId}").` };
305
+ ev = {
306
+ reportRecorded: false, executionConfirmed: false,
307
+ diagnostic: journalled
308
+ ? `the execution report did not reach Decidio (${e?.message ?? e}). Your action DID run and the report is journalled - re-send it with reconfirm("${decisionId}").`
309
+ : `the execution report did not reach Decidio (${e?.message ?? e}) AND could not be journalled locally, so nothing here can re-send it. Your action DID run - record the outcome from your own system.`,
310
+ };
290
311
  }
291
312
  if (ev.reportRecorded)
292
313
  journal.clear(decisionId);
@@ -295,9 +316,9 @@ export function createDecidioResume(opts) {
295
316
  : ev.diagnostic;
296
317
  if (!ev.reportRecorded)
297
318
  console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
298
- else if (!ev.executionConfirmed)
319
+ else if (!ev.executionConfirmed || ev.executionReportVerified === false)
299
320
  console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
300
- return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(diagnostic ? { diagnostic } : {}) };
321
+ return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(ev.executionReportVerified !== undefined ? { executionReportVerified: ev.executionReportVerified } : {}), ...(diagnostic ? { diagnostic } : {}) };
301
322
  }
302
323
  const refusalWarned = new Set();
303
324
  const refuseOnce = (decisionId, msg) => {
@@ -314,11 +335,13 @@ export function createDecidioResume(opts) {
314
335
  workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
315
336
  webhookSecret: secret,
316
337
  store: opts.store,
317
- register(action, fn) {
338
+ register(action, fn, evidence) {
318
339
  const prev = registry.get(action);
319
- if (prev && prev !== fn)
340
+ if (prev && prev.fn !== fn)
320
341
  console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT function — the latest one wins, and a resumed decision would run IT. Give each protected function its own action name.`);
321
- registry.set(action, fn);
342
+ if (prev && prev.evidence && evidence && prev.evidence !== evidence)
343
+ console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT evidence mapper — the latest one wins, and a resumed decision's record would carry ITS output. Give each protected function its own action name.`);
344
+ registry.set(action, { fn, evidence: evidence ?? prev?.evidence });
322
345
  },
323
346
  async park(p) {
324
347
  try {
@@ -368,7 +391,7 @@ export function createDecidioResume(opts) {
368
391
  }
369
392
  if (integrity === "unbound")
370
393
  console.warn(`[decidio] executing ${decisionId} from an UNBOUND parked entry (this agent has no key material to bind or verify payloads) — its arguments cannot be integrity-checked. Run \`npx @decidio/sdk init\` to enable binding.`);
371
- const fn = registry.get(pending.action);
394
+ const fn = registry.get(pending.action)?.fn;
372
395
  if (!fn) {
373
396
  console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
374
397
  console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
@@ -386,13 +409,11 @@ export function createDecidioResume(opts) {
386
409
  return finish(await reportAfterExecution(pending, decisionId, result));
387
410
  }
388
411
  catch (e) {
412
+ inFlight.delete(decisionId);
389
413
  if (ctrl.store.release)
390
414
  await ctrl.store.release(decisionId).catch(() => { });
391
415
  throw e;
392
416
  }
393
- finally {
394
- inFlight.delete(decisionId);
395
- }
396
417
  },
397
418
  webhookHandler() {
398
419
  return (req, res) => {
package/dist/signing.d.ts CHANGED
@@ -68,6 +68,14 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
68
68
  * session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
69
69
  * allowed for local dev; https always allowed. Throws before anything is sent. */
70
70
  export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
71
+ /** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
72
+ * A signed identity proof is a credential too: sending it over remote plaintext disclosed
73
+ * request context and replay/front-run material even with no bearer configured. */
74
+ export declare const carriesCredential: (c: {
75
+ apiToken?: string;
76
+ agentKey?: string;
77
+ agentDid?: string;
78
+ }) => boolean;
71
79
  /** A signed, DOMAIN-SEPARATED, versioned attestation that THIS agent reported THIS captured
72
80
  * response for THIS decision. Distinct from the identity proof (`signConfirm`) in every field
73
81
  * that matters, so a report can never be lifted from one decision onto another: the decision id,
package/dist/signing.js CHANGED
@@ -113,10 +113,26 @@ export function assertJsonSafe(value, path = "args") {
113
113
  export function assertSafeTransport(url, hasCredential) {
114
114
  if (!hasCredential)
115
115
  return;
116
- if (/^http:\/\//i.test(url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(url)) {
117
- throw new Error(`[decidio] refusing to send credentials over plaintext http to ${url.replace(/^(https?:\/\/[^/]+).*$/i, "$1")} — use https, or localhost for local dev. Nothing was sent.`);
116
+ let u;
117
+ try {
118
+ u = new URL(url);
119
+ }
120
+ catch {
121
+ const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "");
122
+ if (/^http:/i.test(spelled)) {
123
+ throw new Error(`[decidio] refusing to send credentials over plaintext http to ${JSON.stringify(url)} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
124
+ }
125
+ return;
118
126
  }
127
+ if (u.protocol !== "http:")
128
+ return;
129
+ const host = u.hostname.toLowerCase();
130
+ const local = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
131
+ if (local && !u.username && !u.password)
132
+ return;
133
+ throw new Error(`[decidio] refusing to send credentials over plaintext http to ${u.origin} — use https, or localhost for local dev. Nothing was sent.`);
119
134
  }
135
+ export const carriesCredential = (c) => !!c.apiToken || !!(c.agentKey && c.agentDid);
120
136
  export function signExecutionReport(privateKeyBase64, fields) {
121
137
  const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
122
138
  const nonce = fields.nonce ?? randomUUID();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",