@decidio/sdk 0.3.3 → 0.3.5

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
@@ -57,6 +57,9 @@ npm install @decidio/sdk
57
57
  npx @decidio/sdk init --unique my-agent # --unique appends a random suffix: collision-proof
58
58
  ```
59
59
 
60
+ > Just ran `npx @decidio/sdk quickstart` in this directory and kept its agent? Its `.env` already
61
+ > works — skip this step and continue at step 3.
62
+
60
63
  Every CLI command talks to the hosted sandbox by default and announces its target host on
61
64
  stderr each run — set `DECIDIO_API_URL` (env or `.env`) to point at another Decidio instead.
62
65
 
@@ -230,7 +233,11 @@ Real approvals take minutes to days, and nobody keeps a terminal open for them.
230
233
  `mode: "blocking"` (durable is the default) and a routed action **suspends** instead of
231
234
  waiting: it parks its call arguments agent-side (`.decidio-pending/` by default — Decidio
232
235
  stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
233
- exit. Two ways the parked action resumes when a human decides:
236
+ exit. **The parked payload is plaintext on the agent's disk:** the full call arguments and their
237
+ signature sit in `.decidio-pending/` until the decision resolves — owner-only permissions where the
238
+ OS supports it, gitignored by `init`, but not encrypted. Treat that directory as you treat the `.env`
239
+ beside it; a custom `PendingStore` can encrypt at rest. Two ways the parked action resumes when a
240
+ human decides:
234
241
 
235
242
  **The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
236
243
  the current process never started. Here is a complete `agent.mjs` you can run:
@@ -421,11 +428,12 @@ the store you configured, so two workers sharing one Redis or Postgres store can
421
428
  other's work. (Through 0.1.x this was a separate local-filesystem journal, which meant two
422
429
  workers kept two disjoint journals and neither could. `reportDir` is accepted and ignored now.)
423
430
 
424
- **Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
425
- disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
426
- `undefined`, `NaN`, class instances with custom `toJSON` — come back changed and fail the
427
- integrity check on resume (a refusal, never a wrong execution). Pass plain data; rehydrate
428
- inside your function.
431
+ **Parked arguments must be JSON-serializable — and that is checked BEFORE routing.** Values that
432
+ do not survive JSON with their meaning intact — `Date`, `Map`, `undefined`, `NaN`, class instances,
433
+ functions — are refused with `DecidioUnsafeArgumentsError` before any decision exists and before any
434
+ network call (since 0.1.12): nothing is parked, nothing is routed. The parked payload is also
435
+ signed, so a file edited on disk fails the integrity check on resume (a refusal, never a wrong
436
+ execution). Pass plain data; rehydrate inside your function.
429
437
 
430
438
  **Payload integrity.** When a routed action is parked, the SDK signs its arguments (bound to the
431
439
  decision id) with your agent key; on resume it **refuses to execute** if the parked payload was
@@ -451,7 +459,7 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
451
459
 
452
460
 
453
461
 
454
- ## The durable store is a revision chain (0.3.2)
462
+ ## The durable store is a revision chain (since 0.3.2)
455
463
 
456
464
  `FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
457
465
  `<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
@@ -484,11 +492,19 @@ the store refuses to pick one.
484
492
 
485
493
  **Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
486
494
  against concurrent callers. The shipped file store did not honor that until now; a custom store must.
487
- The deterministic two-process schedules that proved the defect live in
488
- `packages/conformance/acceptance.mjs` (criteria 7a, 7b and 7c): a denial landing while an invoker
489
- is paused between validating and publishing; two conflicting settlements from one head; and a denier
490
- paused while the approver publishes four revisions past it. A store that cannot pass them is not
491
- atomic, whatever its methods say.
495
+ The deterministic two-process schedules that proved the defect live in the Decidio source
496
+ repository's conformance suite (not part of this package): a denial landing while an invoker is
497
+ paused between validating and publishing; two conflicting settlements from one head; and a denier
498
+ paused while the approver publishes four revisions past it. A store that cannot pass those schedules
499
+ is not atomic, whatever its methods say.
500
+
501
+ **Sizing envelope.** Nothing deletes a revision, so each decision leaves a handful of small files (one
502
+ per transition, typically four to six) and a head lookup lists the directory. That is comfortable for
503
+ tens of thousands of decisions on a local disk; beyond that, or for any deployment that shares a store
504
+ across hosts, use a transactional custom `PendingStore`. The tested envelope is a **single host on a local
505
+ filesystem with hard links** — network, FUSE and multi-host volumes are outside it, and their link
506
+ semantics have not been exercised. Never compact the directory while a worker runs; if compaction is
507
+ ever wanted it is an offline job with every writer stopped.
492
508
 
493
509
  ## Reading a receipt
494
510
 
@@ -510,8 +526,9 @@ request was compared to and did not clear, not a limit that was applied to it. R
510
526
  `requested_amount` as the ask, the other two as ceilings.
511
527
 
512
528
  **`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
513
- auto-approve indicator: a **blocked** request carries the deny-by-default backstop rule here, so
514
- non-empty does not mean "approved" and empty does not mean "blocked". The field that states the
529
+ auto-approve indicator: a request **blocked by the universal backstop** (no active policy governs
530
+ agents at all) carries that backstop rule here, so non-empty does not mean "approved" and empty does
531
+ not mean "blocked". The field that states the
515
532
  disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
516
533
 
517
534
  **`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
@@ -541,8 +558,10 @@ verdict, reason?)` is now REQUIRED — it writes the tombstone that makes a deni
541
558
  permanently unclaimable, and a store that cannot record a denial cannot uphold the guarantee, so
542
559
  this is refused at construction rather than deep inside a resolve. `delete` also takes an optional
543
560
  owning attempt (`delete(id, attemptId?)`) so a superseded attempt cannot clean up the live one's
544
- payload, and `settle` takes `workerStopped`. The shipped stores show the shape, and
545
- `packages/conformance/vectors/ownership.json` is the corpus your store should pass.
561
+ payload, and `settle` takes `workerStopped`. The shipped stores show the shape; the ownership
562
+ corpus that pins these rules (claim fencing, terminal denial, advance-only transitions, settle
563
+ preconditions) lives in the Decidio source repository and is not part of this package — the rules
564
+ themselves are the ones stated above.
546
565
 
547
566
  **`recover()` now throws instead of returning `[]`** when the ledger cannot be read
548
567
  (`DecidioRecoveryUnavailableError`). `[]` and "I cannot tell you what you owe" are the same value
@@ -581,8 +600,9 @@ that fits every other confirm error.
581
600
 
582
601
  - **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
583
602
  the canonical argument model, the transport rule and evidence handling are ONE implementation,
584
- and a conformance matrix runs the same malformed-verdict, unsafe-argument and plaintext-transport
585
- battery against every door. What is *not* yet verified is the engine integration itself: those
603
+ and a conformance matrix runs the same malformed-verdict, unsafe-argument, plaintext-transport and
604
+ malformed-description (blank action, negative amount) battery against every door. What is *not* yet
605
+ verified is the engine integration itself: those
586
606
  paths are exercised against structural mocks, not live LangGraph/Temporal/Inngest/OpenAI
587
607
  runtimes. Use the core path for production until that changes. Each adapter is bound through its
588
608
  own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
@@ -631,7 +651,7 @@ manager for production.
631
651
 
632
652
  ## Errors
633
653
 
634
- - `DecidioBlockedError` — policy blocked the action (incl. the universal deny-by-default backstop).
654
+ - `DecidioBlockedError` — policy blocked the action: a named block rule, or the universal backstop that fires when NO active policy governs agents at all (fail closed, no human involved). Distinct from routing-by-default — with an active policy and no matching auto-approve rule, a request is not blocked, it is ROUTED to a person.
635
655
  - `DecidioRejectedError` — a human rejected the routed action.
636
656
  - `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
637
657
  - `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody, decisionIdProblem } from "../index.js";
2
+ import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, assertDescribed, readConfirmResponse, buildConfirmBody, decisionIdProblem } 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;
@@ -15,6 +15,7 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
15
15
  }
16
16
  export async function gateAuthorize(config, ctx) {
17
17
  ctx = canonicalize(ctx, "describe(...)");
18
+ assertDescribed(ctx);
18
19
  assertSafeTransport(config.apiUrl, carriesCredential(config));
19
20
  const f = config.fetchImpl ?? fetch;
20
21
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
@@ -15,7 +15,7 @@ interface RunStateLike {
15
15
  * park the serialized state agent-side on `route`, resume on Decidio's approval.
16
16
  *
17
17
  * const state = result.state; // or RunState from the run
18
- * const { resolved, pending } = await gateInterruptions(config, state, describe);
18
+ * const { resolved, pending } = await authorizeInterruptions(config, state, describe);
19
19
  * if (pending.length) { await store.put(decisionId, state.toString()); return; } // suspend
20
20
  * result = await run(agent, state); // all resolved → resume
21
21
  * // on Decidio approval webhook: state = await RunState.fromString(agent, parked);
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
5
  import { execFileSync } from "node:child_process";
@@ -38,7 +38,10 @@ function readEnvFile() {
38
38
  }
39
39
  return out;
40
40
  }
41
- const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
41
+ const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
42
+ const ARGV_TAIL = process.argv.slice(2);
43
+ const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].includes(ARGV_TAIL[0])
44
+ || wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]);
42
45
  if (!OFFLINE_CMD) {
43
46
  readEnvFile();
44
47
  if (envUnreadable) {
@@ -109,7 +112,30 @@ async function api(path, init = {}, bearer) {
109
112
  return { status: res.status, body };
110
113
  }
111
114
  function writeSecretFile(path, body) {
112
- writeFileSync(path, body, { mode: 0o600 });
115
+ const tmp = `${path}.${process.pid}.tmp`;
116
+ const fd = openSync(tmp, "w", 0o600);
117
+ try {
118
+ writeSync(fd, body);
119
+ fsyncSync(fd);
120
+ }
121
+ finally {
122
+ closeSync(fd);
123
+ }
124
+ try {
125
+ chmodSync(tmp, 0o600);
126
+ }
127
+ catch { }
128
+ hardenFile(tmp);
129
+ try {
130
+ renameSync(tmp, path);
131
+ }
132
+ catch (e) {
133
+ try {
134
+ rmSync(tmp, { force: true });
135
+ }
136
+ catch { }
137
+ throw e;
138
+ }
113
139
  try {
114
140
  chmodSync(path, 0o600);
115
141
  }
@@ -123,14 +149,79 @@ async function revokeFreshMint(jti, bearer, writeError, target) {
123
149
  err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
124
150
  const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
125
151
  if (rv.status >= 200 && rv.status < 300) {
152
+ journalRetire(jti, "revoked");
126
153
  err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
127
154
  return true;
128
155
  }
156
+ let known = false;
157
+ try {
158
+ known = journalOutstanding().some((j) => j.jti === jti);
159
+ }
160
+ catch { }
161
+ if (known)
162
+ err(` Its id is kept in ${JOURNAL_PATH} (owner-only, append-only) until it is revoked - \`quickstart cleanup\` retries it, and \`doctor\` names it.`);
163
+ else
164
+ err(` Its id is NOT recorded anywhere but this terminal (the journal write is what failed) - write it down now and revoke it by hand.`);
129
165
  err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
130
166
  err(` npx @decidio/sdk tokens revoke ${jti}`);
131
167
  err(` npx @decidio/sdk tokens list --mine`);
132
168
  return false;
133
169
  }
170
+ const JOURNAL_PATH = ".decidio-token-journal";
171
+ function journalAppend(entry) {
172
+ const fresh = !existsSync(JOURNAL_PATH);
173
+ const fd = openSync(JOURNAL_PATH, "a", 0o600);
174
+ try {
175
+ writeSync(fd, JSON.stringify({ ...entry, at: new Date().toISOString() }) + "\n");
176
+ fsyncSync(fd);
177
+ }
178
+ finally {
179
+ closeSync(fd);
180
+ }
181
+ if (fresh) {
182
+ try {
183
+ chmodSync(JOURNAL_PATH, 0o600);
184
+ }
185
+ catch { }
186
+ hardenFile(JOURNAL_PATH);
187
+ }
188
+ }
189
+ function journalProbe() {
190
+ try {
191
+ journalAppend({ event: "probe" });
192
+ return null;
193
+ }
194
+ catch (e) {
195
+ return String(e?.message ?? e);
196
+ }
197
+ }
198
+ function journalMinted(jti, agentId, label) { journalAppend({ event: "minted", jti, agentId, label, apiUrl: API }); }
199
+ function journalRetire(jti, how) { try {
200
+ journalAppend({ event: "retired", jti, how });
201
+ }
202
+ catch { } }
203
+ function journalOutstanding() {
204
+ if (!existsSync(JOURNAL_PATH))
205
+ return [];
206
+ const minted = new Map();
207
+ for (const line of readFileSync(JOURNAL_PATH, "utf8").split(/\r?\n/)) {
208
+ if (!line.trim())
209
+ continue;
210
+ let e;
211
+ try {
212
+ e = JSON.parse(line);
213
+ }
214
+ catch {
215
+ continue;
216
+ }
217
+ if (e?.event === "minted" && typeof e.jti === "string")
218
+ minted.set(e.jti, { jti: e.jti, agentId: e.agentId, label: e.label, apiUrl: e.apiUrl, at: e.at });
219
+ if (e?.event === "retired" && typeof e.jti === "string")
220
+ minted.delete(e.jti);
221
+ }
222
+ return [...minted.values()];
223
+ }
224
+ const REFUSE_MINT_NO_JOURNAL = (why) => `✘ could not write the token recovery journal (${JOURNAL_PATH}: ${why}) - refusing to mint a credential this directory cannot record a revocation handle for. Fix the directory's permissions or free space and re-run. Nothing was minted.`;
134
225
  function upsertEnv(updates) {
135
226
  const remaining = { ...updates };
136
227
  const updatedKeys = new Set();
@@ -180,7 +271,7 @@ function assertNotTracked(path) {
180
271
  return false;
181
272
  }
182
273
  function ensureGitignore() {
183
- const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
274
+ const entries = [".env", ".env.quickstart", ".env.*.bak", ".env.*.tmp", ".decidio-pending/", ".decidio-token-journal"];
184
275
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
185
276
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
186
277
  if (!missing.length)
@@ -319,16 +410,33 @@ async function cmdInit(agentId, opts = {}) {
319
410
  let agentToken, workspaceId, tokenJti;
320
411
  const mintBearer = session ?? TOKEN();
321
412
  if (mintBearer) {
413
+ ensureGitignore();
414
+ const noJournal = journalProbe();
415
+ if (noJournal) {
416
+ err(REFUSE_MINT_NO_JOURNAL(noJournal));
417
+ return 1;
418
+ }
322
419
  const expiresInDays = opts.expiresDays ?? 90;
323
420
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
324
421
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
325
422
  agentToken = mint.body.token;
326
423
  workspaceId = mint.body.workspaceId;
327
424
  tokenJti = mint.body.jti;
425
+ if (tokenJti) {
426
+ try {
427
+ journalMinted(tokenJti, agentId, "sdk-cli");
428
+ }
429
+ catch (e) {
430
+ await revokeFreshMint(tokenJti, mintBearer, e, JOURNAL_PATH);
431
+ return 1;
432
+ }
433
+ }
328
434
  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)`}`);
329
435
  const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
330
- for (const s of superseded)
436
+ for (const s of superseded) {
437
+ journalRetire(s, "revoked");
331
438
  log(` • this replaces the agent's previous token (jti ${s}) — now REVOKED: the server keeps one active token per agent. Anything still using it will 401.`);
439
+ }
332
440
  }
333
441
  else if (session) {
334
442
  err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
@@ -356,6 +464,8 @@ async function cmdInit(agentId, opts = {}) {
356
464
  try {
357
465
  prepareEnvForWrite();
358
466
  upsertEnv(envOut);
467
+ if (tokenJti)
468
+ journalRetire(tokenJti, "persisted");
359
469
  }
360
470
  catch (e) {
361
471
  if (agentToken && tokenJti)
@@ -404,6 +514,17 @@ async function cmdDoctor() {
404
514
  let warned = 0;
405
515
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
406
516
  const warn = (label) => { warned++; log(`⚠ ${label}`); };
517
+ let journalRows = [];
518
+ try {
519
+ journalRows = journalOutstanding();
520
+ }
521
+ catch (e) {
522
+ warn(`token recovery journal (${JOURNAL_PATH}) could not be read (${e?.message ?? e}) — it may hold live token ids; fix its permissions and re-run doctor.`);
523
+ }
524
+ for (const j of journalRows) {
525
+ const where = j.apiUrl && j.apiUrl !== API ? `DECIDIO_API_URL=${j.apiUrl} ` : "";
526
+ warn(`token recovery journal: jti ${j.jti} (${j.label ?? "token"}, agent ${j.agentId ?? "?"}, minted ${j.at ?? "?"} against ${j.apiUrl ?? API}) has no recorded revocation in this directory's journal — it may still be live. Revoke it, or confirm it is gone: ${where}npx @decidio/sdk quickstart cleanup (or: ${where}npx @decidio/sdk tokens revoke ${j.jti})`);
527
+ }
407
528
  log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
408
529
  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>)"}`);
409
530
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
@@ -422,7 +543,7 @@ async function cmdDoctor() {
422
543
  else if (derived !== storedDid)
423
544
  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.`);
424
545
  else
425
- 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).");
546
+ 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 (routing-by-default - a block is different: a named rule, or the universal backstop when no active policy governs agents at all).");
426
547
  }
427
548
  else
428
549
  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.");
@@ -729,11 +850,35 @@ async function cmdQuickstart(opts = {}) {
729
850
  err(` Nothing was written to this directory.`);
730
851
  return 1;
731
852
  }
853
+ let prevTourFile = null;
854
+ if (envFile !== ENV_PATH && existsSync(envFile)) {
855
+ try {
856
+ prevTourFile = readFileSync(envFile, "utf8");
857
+ }
858
+ catch (e) {
859
+ err(`✘ ${envFile} exists but could not be read (${e?.message ?? e}) - a previous tour's credentials may be in it, and this tour would have to replace it. Refusing to mint a new token on top of a file this command cannot read: fix its permissions or move it aside, then re-run. Nothing was minted.`);
860
+ return 1;
861
+ }
862
+ }
863
+ const noJournal = journalProbe();
864
+ if (noJournal) {
865
+ err(REFUSE_MINT_NO_JOURNAL(noJournal));
866
+ return 1;
867
+ }
732
868
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
733
869
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
734
870
  err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
735
871
  return 1;
736
872
  }
873
+ if (mint.body.jti) {
874
+ try {
875
+ journalMinted(mint.body.jti, agentId, "quickstart");
876
+ }
877
+ catch (e) {
878
+ await revokeFreshMint(mint.body.jti, session, e, JOURNAL_PATH);
879
+ return 1;
880
+ }
881
+ }
737
882
  const envOut = {
738
883
  DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
739
884
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
@@ -741,8 +886,8 @@ async function cmdQuickstart(opts = {}) {
741
886
  ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
742
887
  };
743
888
  if (envFile !== ENV_PATH) {
744
- if (existsSync(envFile)) {
745
- const prev = readFileSync(envFile, "utf8");
889
+ if (prevTourFile !== null) {
890
+ const prev = prevTourFile;
746
891
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
747
892
  const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
748
893
  const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
@@ -767,6 +912,8 @@ async function cmdQuickstart(opts = {}) {
767
912
  }
768
913
  try {
769
914
  writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
915
+ if (mint.body.jti)
916
+ journalRetire(mint.body.jti, "persisted");
770
917
  }
771
918
  catch (e) {
772
919
  if (mint.body?.jti)
@@ -780,6 +927,8 @@ async function cmdQuickstart(opts = {}) {
780
927
  else {
781
928
  try {
782
929
  upsertEnv(envOut);
930
+ if (mint.body.jti)
931
+ journalRetire(mint.body.jti, "persisted");
783
932
  }
784
933
  catch (e) {
785
934
  if (mint.body?.jti)
@@ -832,7 +981,9 @@ async function cmdQuickstart(opts = {}) {
832
981
  return failTour(1);
833
982
  }
834
983
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
835
- log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
984
+ log(` so the policy ROUTES it to a person (routing-by-default). That is distinct from a BLOCK, which no human can`);
985
+ log(` override: a block is a named rule, or the universal backstop when no active policy governs agents at all.`);
986
+ log(` Nothing executes until someone decides.`);
836
987
  log("");
837
988
  const interactive = process.stdin.isTTY;
838
989
  if (interactive) {
@@ -913,7 +1064,10 @@ async function cmdQuickstart(opts = {}) {
913
1064
  log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
914
1065
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
915
1066
  log("");
916
- log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
1067
+ log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")}`);
1068
+ log(`Next: the README section "Production mode: durable async resume" (the worker; the engine adapters follow it).`);
1069
+ log(`The README's seven-step quickstart is this same blocking flow in your own code - if you keep this agent, its .env`);
1070
+ log(`already works here, so skip that flow's init step.`);
917
1071
  log(`Built by OmniTwin — decidioai.com · Putting this in front of real approvals? decidioai.com/#early-access`);
918
1072
  let doCleanup = false;
919
1073
  if (interactive) {
@@ -932,14 +1086,47 @@ async function cmdQuickstart(opts = {}) {
932
1086
  async function quickstartCleanup(session, envFile) {
933
1087
  const env = (() => {
934
1088
  const out = {};
935
- if (existsSync(envFile))
936
- for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
937
- const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
938
- if (m)
939
- out[m[1]] = m[2];
940
- }
1089
+ try {
1090
+ if (existsSync(envFile))
1091
+ for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
1092
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
1093
+ if (m)
1094
+ out[m[1]] = m[2];
1095
+ }
1096
+ }
1097
+ catch (e) {
1098
+ err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) - sweeping the token recovery journal instead.`);
1099
+ }
941
1100
  return out;
942
1101
  })();
1102
+ let journalRevoked = 0;
1103
+ const journalStuck = [];
1104
+ for (const j of journalOutstanding()) {
1105
+ if (j.apiUrl && j.apiUrl !== API) {
1106
+ journalStuck.push(j.jti);
1107
+ err(` ⚠ journalled token ${j.jti} was minted against ${j.apiUrl}, not ${API} — revoke it there: DECIDIO_API_URL=${j.apiUrl} npx @decidio/sdk tokens revoke ${j.jti}`);
1108
+ continue;
1109
+ }
1110
+ const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(j.jti)}`, { method: "DELETE" }, session);
1111
+ if (rv.status >= 200 && rv.status < 300) {
1112
+ journalRetire(j.jti, "revoked");
1113
+ journalRevoked++;
1114
+ if (rv.body?.changed === false)
1115
+ log(` • journalled token ${j.jti} was already revoked — retiring its journal entry.`);
1116
+ else
1117
+ log(` ✔ journalled token revoked (jti ${j.jti}${j.label ? `, ${j.label}` : ""}) — its credential file never landed, so this was its only handle`);
1118
+ }
1119
+ else {
1120
+ journalStuck.push(j.jti);
1121
+ err(` ⚠ journalled token ${j.jti} could NOT be revoked (${rv.status}) — it is still live; its id stays in ${JOURNAL_PATH}.`);
1122
+ }
1123
+ }
1124
+ if (journalStuck.length) {
1125
+ err(`✘ ${journalStuck.length} journalled token(s) are STILL LIVE and could not be revoked. Nothing was deleted; retry, or revoke by hand:`);
1126
+ for (const jti of journalStuck)
1127
+ err(` npx @decidio/sdk tokens revoke ${jti}`);
1128
+ return 1;
1129
+ }
943
1130
  const orphans = (env.DECIDIO_ORPHANED_TOKEN_JTI ?? "").split(",").map((s) => s.trim()).filter(Boolean);
944
1131
  const stillOrphaned = [];
945
1132
  for (const jti of orphans) {
@@ -970,6 +1157,9 @@ async function quickstartCleanup(session, envFile) {
970
1157
  return 1;
971
1158
  }
972
1159
  }
1160
+ else if (journalRevoked > 0 && !existsSync(envFile) || (journalRevoked > 0 && Object.keys(env).length === 0)) {
1161
+ log(` • no credential file to clean: the journal sweep revoked ${journalRevoked} token(s) this directory owed.`);
1162
+ }
973
1163
  else {
974
1164
  err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
975
1165
  err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
@@ -978,9 +1168,18 @@ async function quickstartCleanup(session, envFile) {
978
1168
  err(` (it also expires on its own within 7 days)`);
979
1169
  return 1;
980
1170
  }
1171
+ let envRaw = null;
981
1172
  if (existsSync(envFile)) {
1173
+ try {
1174
+ envRaw = readFileSync(envFile, "utf8");
1175
+ }
1176
+ catch (e) {
1177
+ err(` ⚠ ${envFile} could not be read (${e?.message ?? e}) — left as it is; remove it by hand once it is readable.`);
1178
+ }
1179
+ }
1180
+ if (envRaw !== null) {
982
1181
  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", "DECIDIO_ORPHANED_TOKEN_JTI"]);
983
- const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
1182
+ const kept = envRaw.split(/\r?\n/).filter((line) => {
984
1183
  const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=/);
985
1184
  return m ? !TOUR_KEYS.has(m[1]) : line.trim() !== "";
986
1185
  });
@@ -1004,6 +1203,12 @@ async function quickstartCleanup(session, envFile) {
1004
1203
  }
1005
1204
  log(` • What remains, by design: the sealed decision record (records are permanent — that is the product)`);
1006
1205
  log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
1206
+ if (existsSync(".decidio-pending")) {
1207
+ log(` • Also kept: .decidio-pending/ — this agent's own execution ledger: whatever this agent parked (call arguments,`);
1208
+ log(` execution evidence; never credentials, by design). Gitignored; owner-only where the OS supports it. If nothing is`);
1209
+ log(` pending and you do not want the history, remove it:`);
1210
+ log(` ${process.platform === "win32" ? "Remove-Item -Recurse .decidio-pending" : "rm -r .decidio-pending"}`);
1211
+ }
1007
1212
  return 0;
1008
1213
  }
1009
1214
  async function cmdTokens(sub, jti, mineOnly = false) {
@@ -1036,10 +1241,12 @@ async function cmdTokens(sub, jti, mineOnly = false) {
1036
1241
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
1037
1242
  if (rev.status >= 200 && rev.status < 300) {
1038
1243
  if (rev.body?.changed === false) {
1244
+ journalRetire(jti, "revoked");
1039
1245
  log(`• ${jti} was already revoked — nothing changed.`);
1040
1246
  log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
1041
1247
  return 1;
1042
1248
  }
1249
+ journalRetire(jti, "revoked");
1043
1250
  log(`✔ revoked ${jti} — the token is dead now (its agent stays registered; mint a new one with init).`);
1044
1251
  return 0;
1045
1252
  }
@@ -1151,7 +1358,6 @@ Docs: the README that shipped with this package — node_modules/@decidio/sdk/RE
1151
1358
  (quickstart, durable resume, workers, adapters).`;
1152
1359
  async function main(argv) {
1153
1360
  const [cmd, a, b, ...rest] = argv.slice(2);
1154
- const wantsHelp = (s) => s === "help" || s === "--help" || s === "-h";
1155
1361
  if (cmd === "--version" || cmd === "-v" || cmd === "-V" || cmd === "version") {
1156
1362
  log(VERSION);
1157
1363
  return 0;
@@ -1230,9 +1436,10 @@ async function main(argv) {
1230
1436
  case "quickstart": {
1231
1437
  if (argsIn.includes("cleanup")) {
1232
1438
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1233
- : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
1439
+ : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH
1440
+ : journalOutstanding().length ? ".env.quickstart" : undefined;
1234
1441
  if (!envFile) {
1235
- log("Nothing to clean up here: no .env.quickstart, and .env doesn't belong to a quickstart agent.");
1442
+ log("Nothing to clean up here: no .env.quickstart, .env doesn't belong to a quickstart agent, and the token recovery journal owes nothing.");
1236
1443
  return 0;
1237
1444
  }
1238
1445
  const session = await acquireSession("Cleaning the quickstart up");
package/dist/errors.js CHANGED
@@ -26,7 +26,11 @@ export class DecidioHttpError extends Error {
26
26
  (credential
27
27
  ? ` — 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.`
28
28
  : retryable
29
- ? ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`
29
+ ? (/\/agent\/confirm/.test(endpoint)
30
+ ? ` — this looks transient. Your action ALREADY RAN; do NOT re-run it — only its report failed to land, and the door's own note says whether a report was kept to re-send.`
31
+ : /\/agent\/status/.test(endpoint)
32
+ ? ` — this looks transient; polling again is safe (a status read changes nothing).`
33
+ : ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute).`)
30
34
  : ` — the server refused the request itself, so retrying it unchanged will fail the same way. Fix the request (or the decision it names) first.`) +
31
35
  sideEffectNote(endpoint, typeof body?.code === "string" ? body.code : undefined));
32
36
  this.status = status;
package/dist/index.d.ts CHANGED
@@ -103,6 +103,11 @@ export declare class DecidioNetworkError extends Error {
103
103
  code: string | undefined;
104
104
  constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
105
105
  }
106
+ /** THE ONE DESCRIBE VALIDATION (prodsec V033-05). The core wrapper refused a blank `action` and a
107
+ * negative / non-finite `amount` before routing; the adapter gate canonicalized and posted them, so
108
+ * every engine door reached the mock authorization path with a request the core would have refused.
109
+ * Exported so `adapters/_gate.ts` calls the same function rather than a second copy that can drift. */
110
+ export declare function assertDescribed(ctx: ApprovalContext): void;
106
111
  /**
107
112
  * Wrap an async action behind the Decidio gate. `describe` extracts the approval
108
113
  * context (action, amount, scope) from the call arguments. Returns a function with
package/dist/index.js CHANGED
@@ -78,6 +78,14 @@ async function reach(f, url, op, init) {
78
78
  throw new DecidioNetworkError(url, op, e?.cause?.code ?? e?.code, e);
79
79
  }
80
80
  }
81
+ export function assertDescribed(ctx) {
82
+ if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
83
+ throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
84
+ }
85
+ if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
86
+ throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
87
+ }
88
+ }
81
89
  async function post(cfg, path, body) {
82
90
  const f = cfg.fetchImpl ?? fetch;
83
91
  assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
@@ -142,12 +150,7 @@ export function withApproval(fn, describe, config, opts) {
142
150
  const requesterIdentity = (config.agentKey && config.agentDid)
143
151
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
144
152
  : undefined;
145
- if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
146
- throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
147
- }
148
- if (typeof ctx.amount !== "number" || !Number.isFinite(ctx.amount) || ctx.amount < 0) {
149
- throw new Error(`[decidio] describe(...) must return a finite, non-negative \`amount\` — the policy decides on it, so a request without one is not a request Decidio can rule on (got ${JSON.stringify(ctx.amount ?? null)}). Use 0 for an action with no monetary value. Nothing was routed or executed.`);
150
- }
153
+ assertDescribed(ctx);
151
154
  const auth = await post(config, "/agent/authorize", {
152
155
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
153
156
  scope: ctx.scope, sourceSystem: config.sourceSystem,
package/dist/inngest.js CHANGED
@@ -28,6 +28,8 @@ export async function decidioGateStep(args) {
28
28
  export function createInngestResumeBridge(opts) {
29
29
  const secret = opts.webhookSecret ?? "";
30
30
  const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
31
+ if (!secret && opts.allowUnsigned)
32
+ console.warn("[decidio] inngest resume bridge: allowUnsigned=true and no webhookSecret — resume signals are NOT authenticated; anyone who can reach this endpoint can emit a resume event. Development only.");
31
33
  return (req, res) => {
32
34
  if (req.method !== "POST") {
33
35
  res.statusCode = 405;
package/dist/resume.d.ts CHANGED
@@ -450,7 +450,10 @@ export declare class DecidioSuspendedError extends Error {
450
450
  /** `indeterminate` (0.2.0): the handler was INVOKED and then threw. Whether the downstream
451
451
  * effect committed is not knowable from here, so the action is never retried automatically —
452
452
  * it waits for reconcile(). Callers that switch exhaustively on this union must handle it. */
453
- export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate";
453
+ /** `already_resolved` (0.3.5): this resume signal LOST the transition race to an earlier, final answer
454
+ * (a denial, or an execution that already ran) — `reason` names which. It used to read `unknown`,
455
+ * which is the word for "not ours"; safety was right and the diagnosis was not (prodsec V033-06). */
456
+ export type ResumeStatus = "executed" | "rejected" | "blocked" | "no_handler" | "unknown" | "indeterminate" | "already_resolved";
454
457
  /** The states a durable action can actually be in — every one of them nameable, including the
455
458
  * one nobody likes. */
456
459
  export type RecoveryState =
@@ -472,7 +475,9 @@ export type RecoveryState =
472
475
  * and must never be retried automatically. */
473
476
  | "indeterminate"
474
477
  /** Executed, result captured, report not yet accepted by Decidio. Safe to `reconfirm`. */
475
- | "executed_unreported";
478
+ | "executed_unreported"
479
+ /** a parked row whose payload is gone: cannot execute, not awaiting anyone (0.3.5) */
480
+ | "orphaned_state";
476
481
  export interface RecoveryItem {
477
482
  decisionId: string;
478
483
  action?: string;
package/dist/resume.js CHANGED
@@ -1037,7 +1037,7 @@ export function createDecidioResume(opts) {
1037
1037
  refuseOnce(decisionId, claim.state === "discarded"
1038
1038
  ? `[decidio] REFUSING to execute ${decisionId}: it was DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}) and that answer is final. Nothing ran. A denial cannot be reconciled or retried — if this action should now proceed, it needs a NEW request and a new approval.`
1039
1039
  : `[decidio] REFUSING to execute ${decisionId}: its durable state is ${claim.state} — invocation already began, so re-running it could duplicate a committed effect. Inspect it with guard.resume.recover(); settle it with guard.resume.reconcile("${decisionId}", …).`, claim.state === "discarded" ? " (This prints once. The decision is closed — no action is needed.)" : undefined);
1040
- return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "unknown", decisionId, action: pending.action, reason: claim.reason });
1040
+ return finish({ status: claim.state === "indeterminate" ? "indeterminate" : "already_resolved", decisionId, action: pending.action, reason: claim.state === "discarded" ? `this decision was already DENIED (${(await ctrl.store.getExecution(decisionId))?.verdict ?? "rejected"}); that answer is final and this signal lost the race to it` : `this decision already ran (durable state ${claim.state}); this signal lost the race to it` });
1041
1041
  }
1042
1042
  return { status: "unknown", decisionId };
1043
1043
  }
@@ -1269,7 +1269,12 @@ export function createDecidioResume(opts) {
1269
1269
  guidance: `"${a}" was claimed at ${r.at} by attempt ${r.attemptId ?? "(unknown)"} which never reached the invocation boundary — the handler provably did not run, and nothing will retry it on its own. Release it: guard.resume.reconcile("${r.decisionId}", { outcome: "not_committed", reason: "claimed but never invoked" }).` });
1270
1270
  break;
1271
1271
  case "parked":
1272
- items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1272
+ if (!(await ctrl.store.get(r.decisionId))) {
1273
+ items.push({ decisionId: r.decisionId, action: r.action, state: "orphaned_state", guidance: `"${a}" is recorded as parked but its payload is gone, so nothing can execute it — an orphaned row (usually a legacy denial whose state was never written), not work awaiting a human. Nothing will run. It needs no action; remove its revision files if you want the listing clean.` });
1274
+ }
1275
+ else {
1276
+ items.push({ decisionId: r.decisionId, action: r.action, state: "parked", guidance: `"${a}" is parked awaiting a human decision. Nothing has executed.` });
1277
+ }
1273
1278
  break;
1274
1279
  case "discarded":
1275
1280
  if (r.executionDespiteDenial) {
package/dist/signing.d.ts CHANGED
@@ -74,6 +74,10 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
74
74
  * rule is about the WIRE, not which credential (the interactive sign-in enforced this for
75
75
  * session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
76
76
  * allowed for local dev; https always allowed. Throws before anything is sent. */
77
+ /** The URL as it may be ECHOED in a refusal: userinfo masked. The SDK treats URL userinfo as a credential
78
+ * (it is refused as one), so no refusal may print it - every branch of assertSafeTransport echoes through
79
+ * this, not the raw string (invariant-guard, 0.3.5). */
80
+ export declare function redactUrl(u: string): string;
77
81
  export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
78
82
  /** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
79
83
  * A signed identity proof is a credential too: sending it over remote plaintext disclosed
package/dist/signing.js CHANGED
@@ -106,7 +106,17 @@ export function assertJsonSafe(value, path = "args") {
106
106
  };
107
107
  walk(value, path);
108
108
  }
109
+ export function redactUrl(u) { return u.replace(/\/\/[^/@]*@/, "//***@"); }
109
110
  export function assertSafeTransport(url, hasCredential) {
111
+ if (typeof url !== "string") {
112
+ throw new Error(`[decidio] the gate URL is ${url === null ? "null" : typeof url}, not a string, so there is no host to send to. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
113
+ }
114
+ for (const ch of url) {
115
+ const cp = ch.codePointAt(0);
116
+ if (cp <= 0x20 || (cp >= 0x7f && cp <= 0x9f) || cp === 0xfeff) {
117
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} contains whitespace or a control character (U+${cp.toString(16).toUpperCase().padStart(4, "0")}) - a validator and a transport can read such a spelling differently, so it is refused as written. Remove it. Nothing was sent.`);
118
+ }
119
+ }
110
120
  let u;
111
121
  try {
112
122
  u = new URL(url);
@@ -114,9 +124,9 @@ export function assertSafeTransport(url, hasCredential) {
114
124
  catch {
115
125
  const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "");
116
126
  if (/^http:/i.test(spelled)) {
117
- throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} 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.`);
127
+ throw new Error(`[decidio] refusing to ${hasCredential ? "send credentials" : "send a request"} over plaintext http to ${JSON.stringify(redactUrl(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.`);
118
128
  }
119
- throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
129
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} is not an absolute URL, so there is no host to send to — where a request would actually connect depends entirely on whichever transport resolves it. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
120
130
  }
121
131
  if (u.protocol !== "http:" && u.protocol !== "https:") {
122
132
  throw new Error(`[decidio] the gate URL uses the ${u.protocol.replace(":", "")} scheme, which is not a Decidio endpoint — use https (or http://localhost for local dev). Nothing was sent.`);
@@ -125,14 +135,14 @@ export function assertSafeTransport(url, hasCredential) {
125
135
  throw new Error(`[decidio] the gate URL carries credentials in the URL itself (${u.protocol}//user:***@${u.host}). Decidio authenticates with a bearer token; remove the user:password from the URL. Nothing was sent.`);
126
136
  }
127
137
  if (u.hostname === "") {
128
- throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
138
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
129
139
  }
130
140
  {
131
141
  const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "").toLowerCase();
132
142
  const origin = `${u.protocol}//${u.host}`.toLowerCase();
133
143
  const next = spelled.charAt(origin.length);
134
144
  if (!spelled.startsWith(origin) || (next !== "" && next !== "/" && next !== "?" && next !== "#" && next !== ":")) {
135
- throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
145
+ throw new Error(`[decidio] the gate URL ${JSON.stringify(redactUrl(url))} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
136
146
  }
137
147
  }
138
148
  if (u.protocol !== "http:")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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",
@@ -82,7 +82,7 @@
82
82
  "prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
83
83
  },
84
84
  "dependencies": {
85
- "@decidio/verify": "0.3.3"
85
+ "@decidio/verify": "0.3.5"
86
86
  },
87
87
  "author": "OmniTwin Technologies Inc.",
88
88
  "bugs": {