@decidio/sdk 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/resume.js CHANGED
@@ -1,27 +1,9 @@
1
- // @decidio/sdk — durable async resume.
2
- //
3
- // The hard problem behind a "one-line" agent gate: a real human approval takes
4
- // minutes to days. Nobody watches a 60-second polling window. So a routed action
5
- // must SUSPEND (the requesting process may even exit) and RESUME later when the
6
- // approval arrives — and the AGENT must perform its own action on resume. Decidio
7
- // stays the approval layer above: it authorizes, records, and (on approval) SIGNALS
8
- // the agent. It never holds the agent's downstream credentials and never executes
9
- // the write itself. This is the same shape as HumanLayer's webhooks, Temporal
10
- // Signals, Inngest waitForEvent, and Decidio's own A2A `input-required` state.
11
- //
12
- // Two resume transports, same core:
13
- // • webhookHandler() — production: Decidio POSTs a signed verdict to the agent's
14
- // resume URL; the handler re-executes the agent's own fn and confirms.
15
- // • worker() — no inbound URL (laptop/CLI): a long-lived watcher polls
16
- // /agent/status for each pending decision, then re-executes on approval.
17
- // Either way the args are replayed from an AGENT-SIDE store (Decidio holds none of
18
- // the downstream payload — only the authority decision), and execution is single-use
19
- // (the pending entry is deleted on completion, so a duplicate signal can't double-write).
20
1
  import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
21
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
22
3
  import { join } from "node:path";
23
- import { signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
24
- // ---- parked-payload integrity (round-6 acceptance catch) -------------------------------------
4
+ import { userInfo } from "node:os";
5
+ import { execFileSync } from "node:child_process";
6
+ import { signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
25
7
  const argsBindingBytes = (p) => Buffer.from(canonicalize({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
26
8
  function signArgs(p, agentKey, webhookSecret) {
27
9
  const bytes = argsBindingBytes(p);
@@ -30,7 +12,7 @@ function signArgs(p, agentKey, webhookSecret) {
30
12
  const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
31
13
  return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
32
14
  }
33
- catch { /* fall through to hmac */ }
15
+ catch { }
34
16
  }
35
17
  if (webhookSecret)
36
18
  return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
@@ -68,16 +50,23 @@ export class MemoryPendingStore {
68
50
  return false; this.claimed.add(id); return true; }
69
51
  async release(id) { this.claimed.delete(id); }
70
52
  }
71
- /** File-backed store so a COLD process (the original requester long gone) can still
72
- * resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
73
- * renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
74
- * sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
75
- * TTL recovery — tracked in the backlog.) */
76
53
  export class FilePendingStore {
77
54
  dir;
78
55
  constructor(dir) {
79
56
  this.dir = dir;
80
57
  mkdirSync(dir, { recursive: true });
58
+ if (process.platform === "win32") {
59
+ try {
60
+ const user = (() => { try {
61
+ return userInfo().username;
62
+ }
63
+ catch {
64
+ return process.env.USERNAME ?? process.env.USER ?? "";
65
+ } })();
66
+ execFileSync("icacls", [dir, "/inheritance:r", "/grant:r", `${user}:(OI)(CI)F`], { stdio: "ignore" });
67
+ }
68
+ catch { }
69
+ }
81
70
  }
82
71
  key(id) { return id.replace(/[^\w.-]/g, "_"); }
83
72
  path(id) { return join(this.dir, `${this.key(id)}.json`); }
@@ -95,12 +84,8 @@ export class FilePendingStore {
95
84
  async list() {
96
85
  if (!existsSync(this.dir))
97
86
  return [];
98
- // ONE BAD FILE MUST NOT STALL THE QUEUE: an unparseable entry (partial write, disk corruption,
99
- // a human editing it) used to throw out of list() and take every other parked action down with
100
- // it — a worker that can never poll is a worker that never resumes anything. Skip it loudly
101
- // and keep the rest moving; the bad file stays on disk for inspection.
102
87
  const out = [];
103
- for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) { // .claimed excluded — in flight
88
+ for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
104
89
  try {
105
90
  out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
106
91
  }
@@ -114,7 +99,7 @@ export class FilePendingStore {
114
99
  try {
115
100
  renameSync(this.path(id), this.claimedPath(id));
116
101
  return true;
117
- } // atomic; fails if already claimed/gone
102
+ }
118
103
  catch {
119
104
  return false;
120
105
  }
@@ -123,13 +108,9 @@ export class FilePendingStore {
123
108
  try {
124
109
  renameSync(this.claimedPath(id), this.path(id));
125
110
  }
126
- catch { /* already released/gone */ }
111
+ catch { }
127
112
  }
128
113
  }
129
- /** Thrown by a `withApproval`-wrapped call when the action is routed and configured
130
- * for async resume: the action is now SUSPENDED (parked in the store), not failed.
131
- * The caller can stop here — the process may even exit; the resume transport will
132
- * complete it later. */
133
114
  export class DecidioSuspendedError extends Error {
134
115
  decisionId;
135
116
  action;
@@ -144,8 +125,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
144
125
  function sign(secret, body) {
145
126
  return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
146
127
  }
147
- /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
148
- * Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
149
128
  export function verifyResumeSignature(secret, body, header) {
150
129
  if (!header)
151
130
  return false;
@@ -153,11 +132,6 @@ export function verifyResumeSignature(secret, body, header) {
153
132
  const a = Buffer.from(expected), b = Buffer.from(header);
154
133
  return a.length === b.length && timingSafeEqual(a, b);
155
134
  }
156
- /** The ONE confirm-body rule, used by ALL THREE TS confirm doors (resume, inline withApproval,
157
- * adapter gate — guard equivalent-path catch: it lived only on the resume door). Transport
158
- * success is not confirmation (prodsec F-03): a 2xx whose body carries no recorded tier is a
159
- * FAILED confirm; a SIGNED confirm coming back below application_confirmed is disclosed, never
160
- * silent. Returns the recorded tier or throws. */
161
135
  export function assertConfirmRecorded(body, decisionId, signed) {
162
136
  if (!body || body.ok !== true || typeof body.confirmation !== "string") {
163
137
  throw new Error(`confirm answered 2xx but reported no evidence tier — treating the confirmation as NOT recorded`);
@@ -167,14 +141,10 @@ export function assertConfirmRecorded(body, decisionId, signed) {
167
141
  }
168
142
  return body.confirmation;
169
143
  }
170
- /** Resume-door confirm: POST + the shared body rule above. */
171
144
  async function confirm(cfg, decisionId, capturedResponse) {
172
145
  const f = cfg.fetchImpl ?? fetch;
173
- // Sign the confirm so the DURABLE/resume path can earn application_confirmed too (bound to
174
- // {action:"confirm", scope:decisionId}); without a key the server caps it at agent_asserted.
146
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
175
147
  const requesterIdentity = signConfirm(decisionId, cfg);
176
- // Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch): the durable
177
- // path is what production agents run, so it must disclose an agent_asserted cap too.
178
148
  if (!requesterIdentity)
179
149
  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.`);
180
150
  const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
@@ -189,6 +159,7 @@ async function confirm(cfg, decisionId, capturedResponse) {
189
159
  }
190
160
  async function statusOf(cfg, decisionId) {
191
161
  const f = cfg.fetchImpl ?? fetch;
162
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
192
163
  const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
193
164
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
194
165
  });
@@ -196,24 +167,10 @@ async function statusOf(cfg, decisionId) {
196
167
  throw new Error(`status ${r.status}`);
197
168
  return r.json();
198
169
  }
199
- /**
200
- * Build the resume controller. Spread it into your GuardConfig as `resume`, then
201
- * either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
202
- *
203
- * const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
204
- * const guard = { apiUrl, agentId, apiToken, resume };
205
- * const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
206
- * // production: http.createServer(resume.webhookHandler()).listen(4100)
207
- * // or CLI: resume.worker()
208
- */
209
170
  export function createDecidioResume(opts) {
210
171
  const registry = new Map();
211
- const inFlight = new Set(); // concurrent-duplicate guard (same process)
172
+ const inFlight = new Set();
212
173
  const secret = opts.webhookSecret ?? opts.apiToken ?? "";
213
- // CONTROLLER-scoped, not resolve()-scoped (guard catch: declared inside resolve, this Set was
214
- // reborn empty every poll — the refusal printed 3/3 times, each ending with a false "prints
215
- // once"). Refusals repeat on every cycle while the file sits parked; the MESSAGE must not.
216
- // The opt-out of webhook authenticity must never be silent (prodsec F-07).
217
174
  if (opts.allowUnsigned)
218
175
  console.warn("[decidio] WARNING: allowUnsigned is ON — this controller will accept UNSIGNED resume webhooks. Anyone who can reach the endpoint can signal work (parked-payload integrity still gates execution). Use this only behind a trusted network boundary, never on an internet-reachable endpoint.");
219
176
  const refusalWarned = new Set();
@@ -225,29 +182,25 @@ export function createDecidioResume(opts) {
225
182
  };
226
183
  const ctrl = {
227
184
  ...opts,
228
- // Default the signing identity from env so a COLD resume process (a fresh worker that has
229
- // the agent's env but wasn't passed config) still signs the confirm and earns the wrapper tier.
230
185
  agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
231
186
  agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
232
187
  agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
233
- workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID, // D-B3.6: tenant-bind the cold-resume confirm proof
188
+ workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
234
189
  webhookSecret: secret,
235
190
  store: opts.store,
236
191
  register(action, fn) {
237
- // Silent last-writer-wins is the wrong default for an EXECUTION registry: two wrappers
238
- // sharing an action name would let a resumed decision replay its args into the other
239
- // function. Say so loudly — the parked args and the fn must be the pair that was routed.
240
192
  const prev = registry.get(action);
241
193
  if (prev && prev !== fn)
242
194
  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.`);
243
195
  registry.set(action, fn);
244
196
  },
245
197
  async park(p) {
246
- // GATE, not serialization (prodsec F-01): a value that cannot survive JSON with its meaning
247
- // intact must be REFUSED before it is signed or parked — the signature covers canonical
248
- // bytes, and NaN/Map collide with null/{} in those bytes, so it would verify and execute
249
- // with drifted semantics. Refusal here means no decision, no signature, no file.
250
- assertJsonSafe(p.args, "args");
198
+ try {
199
+ assertJsonSafe(p.args, "args");
200
+ }
201
+ catch (e) {
202
+ throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} NOT parked and NOT executable — but decision ${p.decisionId} may already be routed; reject it in your queue: npx @decidio/sdk approvals reject ${p.decisionId}`, p.decisionId);
203
+ }
251
204
  const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
252
205
  if (!argsSig)
253
206
  console.warn(`[decidio] parking ${p.decisionId} UNBOUND — no agent key or webhook secret to sign the payload with, so a modified pending file could not be detected. Run \`npx @decidio/sdk init\` to configure keys.`);
@@ -258,7 +211,7 @@ export function createDecidioResume(opts) {
258
211
  opts.onResolved?.(o); return o; };
259
212
  const pending = await ctrl.store.get(decisionId);
260
213
  if (!pending)
261
- return { status: "unknown", decisionId }; // not ours / already resumed
214
+ return { status: "unknown", decisionId };
262
215
  if (verdict === "rejected") {
263
216
  await ctrl.store.delete(decisionId);
264
217
  return finish({ status: "rejected", decisionId, action: pending.action, reason });
@@ -267,24 +220,12 @@ export function createDecidioResume(opts) {
267
220
  await ctrl.store.delete(decisionId);
268
221
  return finish({ status: "blocked", decisionId, action: pending.action, reason });
269
222
  }
270
- // Fail CLOSED on any unexpected verdict — never execute on a garbage/unknown value.
271
223
  if (verdict !== "approved" && verdict !== "auto_approved")
272
224
  return { status: "unknown", decisionId };
273
- // INTEGRITY BEFORE EXECUTION (round-6 acceptance catch: a locally edited pending file
274
- // executed different arguments from those routed for approval).
275
- // SLOT ASSERTION FIRST (guard catch): the store keys on the FILENAME while the signature
276
- // covers the CONTENT's decisionId — without this line, copying validly-signed entry A into
277
- // slot B executes A's args when B is approved. No forgery needed, so it must be checked
278
- // even before signature verification.
279
225
  if (pending.decisionId !== decisionId) {
280
226
  refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked entry in this slot belongs to a DIFFERENT decision (${pending.decisionId}) — the pending file was moved or swapped. Delete it and re-run the action.`);
281
227
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
282
228
  }
283
- // STRICT binding is keyed to the agent's ED25519 signing key (what `init` writes) — an
284
- // init'd agent always signs via park(), so an unbound entry there is tampering (incl. a
285
- // stripped signature). A bare store.put user with no agent key keeps the documented
286
- // low-level behavior (execute+warn); the apiToken-derived HMAC secret does NOT trigger
287
- // strict mode (it lives in .env next to the pending dir — not a local-tamper defense).
288
229
  const strictBinding = !!ctrl.agentKey;
289
230
  const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
290
231
  if (integrity === "mismatch") {
@@ -296,9 +237,6 @@ export function createDecidioResume(opts) {
296
237
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
297
238
  }
298
239
  if (integrity === "unbound" && strictBinding) {
299
- // A controller WITH key material would have signed at park time, so a MISSING signature is
300
- // itself tampering — an attacker stripping argsSig to reach the legacy path. Refuse. Only a
301
- // keyless controller (genuine legacy / no-key deploy) may execute an unbound entry.
302
240
  refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload has NO integrity signature, but this agent has keys and would have signed it — a missing signature means the file was tampered with. Delete it and re-run the action.`);
303
241
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
304
242
  }
@@ -306,8 +244,6 @@ export function createDecidioResume(opts) {
306
244
  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.`);
307
245
  const fn = registry.get(pending.action);
308
246
  if (!fn) {
309
- // Actionable, because this is the failure a restarted worker actually hits: the registry
310
- // is per-process, and a cold process never ran the original call that would have filled it.
311
247
  console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
312
248
  console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
313
249
  console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
@@ -316,19 +252,11 @@ export function createDecidioResume(opts) {
316
252
  console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
317
253
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" in this process — declare { action } on protect(), or call guard.register("${pending.action}", fn) at startup` });
318
254
  }
319
- // Atomically CLAIM before executing — the cross-process single-use guarantee. If
320
- // the store has no claim primitive, fall back to an in-process guard (same-process).
321
255
  const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
322
256
  if (!claimed)
323
- return { status: "unknown", decisionId }; // another worker owns it
257
+ return { status: "unknown", decisionId };
324
258
  try {
325
- // Approved: the AGENT performs its own action FIRST. If it throws, RELEASE the claim so
326
- // the entry stays parked + retryable — an approved action is never lost.
327
259
  const result = await fn(...pending.args);
328
- // CONFIRM BEFORE DELETE. Deleting first meant a failed confirm lost the only local record
329
- // that this action had executed — the evidence could never be re-sent. The entry stays
330
- // CLAIMED throughout (claim, not presence, is what prevents double-execution), so keeping
331
- // it a moment longer cannot cause a second run.
332
260
  let confirmed = true;
333
261
  let tier;
334
262
  try {
@@ -338,12 +266,6 @@ export function createDecidioResume(opts) {
338
266
  confirmed = false;
339
267
  console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
340
268
  }
341
- // RETAIN-vs-DELETE on a failed confirm. Keeping the entry preserves local evidence that
342
- // this action executed — but ONLY a store with claim() can guarantee a retained entry
343
- // never runs again (its claim refuses it; the in-process fallback dies with the process).
344
- // For a claim-LESS store the delete IS the single-use guarantee, so it must still happen:
345
- // an undelivered confirmation is an evidence gap, a second execution is a double write,
346
- // and we never trade the second away for the first.
347
269
  const retainedIsSafe = typeof ctrl.store.claim === "function";
348
270
  if (confirmed || !retainedIsSafe)
349
271
  await ctrl.store.delete(decisionId);
@@ -374,9 +296,6 @@ export function createDecidioResume(opts) {
374
296
  req.on("data", (c) => (raw += c));
375
297
  req.on("end", async () => {
376
298
  try {
377
- // Fail CLOSED when no secret is configured: an agent that forgot to set a
378
- // webhookSecret must NOT be drivable by forged callbacks. Opt out only with
379
- // allowUnsigned (trusted network boundary).
380
299
  if (!secret) {
381
300
  if (!opts.allowUnsigned) {
382
301
  res.statusCode = 401;
@@ -443,14 +362,8 @@ export function createDecidioResume(opts) {
443
362
  st.state === "blocked" ? "blocked" : null;
444
363
  if (!verdict)
445
364
  continue;
446
- // A throwing resolve (the agent's own fn failed) must NOT kill the watcher —
447
- // the entry stays parked and is retried on the next poll.
448
365
  try {
449
366
  const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
450
- // ONE line per lifecycle event, on stderr (round-3 blind catch: the
451
- // production-recommended worker executed, restart-resumed and discarded actions in
452
- // total silence — the only evidence was a file vanishing). onResolved remains the
453
- // programmatic hook; this is the human-visible floor.
454
367
  if (outcome.status === "executed" && outcome.confirmed)
455
368
  console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, evidence recorded as ${outcome.evidenceTier ?? "confirmed"}.`);
456
369
  else if (outcome.status === "executed")
package/dist/signing.d.ts CHANGED
@@ -46,11 +46,21 @@ export declare function signBoundProof(privateKeyBase64: string, fields: {
46
46
  now?: number;
47
47
  nonce?: string;
48
48
  }): RequesterIdentity;
49
- /** The values that do not survive `JSON.stringify → JSON.parse` with their meaning intact.
50
- * Refused at the GATE, before anything is routed, signed, or parked (prodsec F-01: `NaN`
51
- * canonicalized to `null` and a `Map` to `{}`, so the integrity signature matched the drifted
52
- * bytes and the function EXECUTED with arguments that meant something else — the exact wrong
53
- * execution the parked-payload signature exists to refuse). Throws naming the offending path;
54
- * returns nothing. The supported data model is plain JSON: null, boolean, string, finite
49
+ /** Refuse values that do not survive `JSON.stringify → JSON.parse` with their meaning intact,
50
+ * BEFORE anything is routed, signed, or parked. `NaN` canonicalizes to `null` and a `Map` to
51
+ * `{}` — an integrity signature over those bytes would verify while the function executed
52
+ * arguments that meant something else, so the gate refuses them instead. Throws naming the
53
+ * offending path. The supported data model is plain JSON: null, boolean, string, finite
55
54
  * number, plain array, plain object. */
55
+ /** Typed refusal for values that cannot survive JSON with their meaning intact. `decisionId`
56
+ * is set only when a decision already exists (defense-in-depth refusals after routing). */
57
+ export declare class DecidioUnsafeArgumentsError extends Error {
58
+ decisionId?: string | undefined;
59
+ constructor(message: string, decisionId?: string | undefined);
60
+ }
56
61
  export declare function assertJsonSafe(value: unknown, path?: string): void;
62
+ /** The wire rule: a credentialed request must never ride plaintext http to a remote host — the
63
+ * rule is about the WIRE, not which credential (the interactive sign-in enforced this for
64
+ * session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
65
+ * allowed for local dev; https always allowed. Throws before anything is sent. */
66
+ export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
package/dist/signing.js CHANGED
@@ -1,13 +1,4 @@
1
- // Client-side agent identity (§13 P0-8). The agent generates its Ed25519 keypair LOCALLY
2
- // (the private key never crosses the wire — `init` registers only the public did:key) and
3
- // signs a REQUEST-BOUND proof on each authorize: a detached Ed25519 JWS over
4
- // canonical({agentId,did,action,amount,scope,nonce,exp}). Decidio's floor verifies the
5
- // signature, binds it to the exact request, burns the single-use nonce, and checks the
6
- // did against the agent registry — so the gate PROVES who is calling instead of trusting a
7
- // label. This canonicalize + proof shape is byte-identical to apps/api (canonical.ts +
8
- // a2a/identity.ts), so a proof signed here verifies there.
9
1
  import { generateKeyPairSync, createPrivateKey, sign as edSign, randomUUID } from "node:crypto";
10
- // base58btc (Bitcoin alphabet) — for the did:key multibase 'z' prefix.
11
2
  const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
12
3
  function b58encode(buf) {
13
4
  const digits = [0];
@@ -34,7 +25,6 @@ function b58encode(buf) {
34
25
  str += B58[digits[i]];
35
26
  return str;
36
27
  }
37
- /** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
38
28
  export function canonicalize(v) {
39
29
  if (v === null || typeof v !== "object")
40
30
  return JSON.stringify(v);
@@ -43,9 +33,6 @@ export function canonicalize(v) {
43
33
  const o = v;
44
34
  return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(o[k])}`).join(",")}}`;
45
35
  }
46
- /** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
47
- * and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
48
- * it never goes back to Decidio). */
49
36
  export function generateKeypair() {
50
37
  const { privateKey, publicKey } = generateKeyPairSync("ed25519");
51
38
  const jwk = publicKey.export({ format: "jwk" });
@@ -54,40 +41,29 @@ export function generateKeypair() {
54
41
  const privateKeyBase64 = privateKey.export({ format: "der", type: "pkcs8" }).toString("base64");
55
42
  return { did, privateKeyBase64 };
56
43
  }
57
- /** Sign the CONFIRM call (bound to {action:"confirm", scope:decisionId}) so the gate can
58
- * prove the caller is the receipt's original requester before granting the wrapper tier
59
- * (application_confirmed). Returns undefined when no signing key is configured (the server
60
- * then caps the report at agent_asserted). Used by EVERY confirm path — inline, durable
61
- * resume, and the engine adapters — so the production paths confirm with a proof too. Also
62
- * binds `workspaceId` (D-B3.6) when configured, so the confirm proof is tenant-scoped too. */
63
44
  export function signConfirm(decisionId, cfg) {
64
45
  if (!cfg.agentKey || !cfg.agentDid || !cfg.agentId)
65
46
  return undefined;
66
47
  return signBoundProof(cfg.agentKey, { agentId: cfg.agentId, did: cfg.agentDid, action: "confirm", amount: 0, scope: decisionId, workspaceId: cfg.workspaceId });
67
48
  }
68
- /** Sign a request-bound identity proof. The returned object is sent verbatim as the
69
- * `requesterIdentity` field of /agent/authorize. */
70
49
  export function signBoundProof(privateKeyBase64, fields) {
71
50
  const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
72
51
  const nonce = fields.nonce ?? randomUUID();
73
52
  const exp = (fields.now ?? Date.now()) + (fields.ttlMs ?? 120_000);
74
- // OMIT scope/workspaceId when absent so the canonical bytes match the server + the Python SDK — a key
75
- // with an undefined value would serialize as literal "undefined", which Python can't reproduce, and a
76
- // legacy (no-workspace) proof must stay byte-identical. The server's bound-payload reconstruction
77
- // omits them the same way (a2a/identity.ts).
78
53
  const payloadObj = { agentId: fields.agentId, did: fields.did, action: fields.action, amount: fields.amount, ...(fields.scope !== undefined ? { scope: fields.scope } : {}), ...(fields.workspaceId !== undefined ? { workspaceId: fields.workspaceId } : {}), nonce, exp };
79
54
  const payload = Buffer.from(canonicalize(payloadObj), "utf8").toString("base64url");
80
55
  const protectedHdr = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: fields.did }), "utf8").toString("base64url");
81
56
  const signature = Buffer.from(edSign(null, Buffer.from(`${protectedHdr}.${payload}`, "utf8"), key)).toString("base64url");
82
57
  return { agentId: fields.agentId, did: fields.did, action: fields.action, amount: fields.amount, scope: fields.scope, workspaceId: fields.workspaceId, nonce, exp, proof: { protected: protectedHdr, signature } };
83
58
  }
84
- /** The values that do not survive `JSON.stringify → JSON.parse` with their meaning intact.
85
- * Refused at the GATE, before anything is routed, signed, or parked (prodsec F-01: `NaN`
86
- * canonicalized to `null` and a `Map` to `{}`, so the integrity signature matched the drifted
87
- * bytes and the function EXECUTED with arguments that meant something else — the exact wrong
88
- * execution the parked-payload signature exists to refuse). Throws naming the offending path;
89
- * returns nothing. The supported data model is plain JSON: null, boolean, string, finite
90
- * number, plain array, plain object. */
59
+ export class DecidioUnsafeArgumentsError extends Error {
60
+ decisionId;
61
+ constructor(message, decisionId) {
62
+ super(message);
63
+ this.decisionId = decisionId;
64
+ this.name = "DecidioUnsafeArgumentsError";
65
+ }
66
+ }
91
67
  export function assertJsonSafe(value, path = "args") {
92
68
  const seen = new Set();
93
69
  const walk = (v, p) => {
@@ -95,18 +71,18 @@ export function assertJsonSafe(value, path = "args") {
95
71
  return;
96
72
  if (typeof v === "number") {
97
73
  if (!Number.isFinite(v))
98
- throw new Error(`[decidio] ${p} is ${String(v)} — a non-finite number does not survive JSON (it becomes null), so what a human approves would not be what executes. The decision was NOT routed; pass a finite number or a string.`);
74
+ throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is ${String(v)} — a non-finite number does not survive JSON (it becomes null), so what a human approves would not be what executes. pass a finite number or a string.`);
99
75
  return;
100
76
  }
101
77
  if (typeof v === "undefined")
102
- throw new Error(`[decidio] ${p} is undefined — JSON drops or nullifies it, so what a human approves would not be what executes. The decision was NOT routed; use null explicitly or omit the field.`);
78
+ throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is undefined — JSON drops or nullifies it, so what a human approves would not be what executes. use null explicitly or omit the field.`);
103
79
  if (typeof v === "bigint")
104
- throw new Error(`[decidio] ${p} is a BigInt — JSON cannot represent it. The decision was NOT routed; pass it as a string.`);
80
+ throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is a BigInt — JSON cannot represent it. pass it as a string.`);
105
81
  if (typeof v === "function" || typeof v === "symbol")
106
- throw new Error(`[decidio] ${p} is a ${typeof v} — it cannot be serialized for approval. The decision was NOT routed.`);
82
+ throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is a ${typeof v} — it cannot be serialized for approval.`);
107
83
  if (typeof v === "object") {
108
84
  if (seen.has(v))
109
- throw new Error(`[decidio] ${p} is part of a circular structure — it cannot be serialized for approval. The decision was NOT routed.`);
85
+ throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is part of a circular structure — it cannot be serialized for approval.`);
110
86
  seen.add(v);
111
87
  if (Array.isArray(v)) {
112
88
  v.forEach((x, i) => walk(x, `${p}[${i}]`));
@@ -116,16 +92,23 @@ export function assertJsonSafe(value, path = "args") {
116
92
  const proto = Object.getPrototypeOf(v);
117
93
  if (proto !== Object.prototype && proto !== null) {
118
94
  const name = v?.constructor?.name ?? "a class instance";
119
- throw new Error(`[decidio] ${p} is ${name === "Object" ? "an exotic object" : `a ${name}`} — only plain JSON objects survive serialization with their meaning intact (a Map, for example, becomes {}). The decision was NOT routed; convert it to plain JSON first.`);
95
+ throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is ${name === "Object" ? "an exotic object" : `a ${name}`} — only plain JSON objects survive serialization with their meaning intact (a Map, for example, becomes {}); convert it to plain JSON first.`);
120
96
  }
121
97
  if (typeof v.toJSON === "function")
122
- throw new Error(`[decidio] ${p} defines toJSON() — its serialized form would differ from the value the signature covers. The decision was NOT routed; pass the plain serialized form instead.`);
98
+ throw new DecidioUnsafeArgumentsError(`[decidio] ${p} defines toJSON() — its serialized form would differ from the value the signature covers. pass the plain serialized form instead.`);
123
99
  for (const [k, x] of Object.entries(v))
124
100
  walk(x, `${p}.${k}`);
125
101
  seen.delete(v);
126
102
  return;
127
103
  }
128
- throw new Error(`[decidio] ${p} has unsupported type ${typeof v}. The decision was NOT routed.`);
104
+ throw new DecidioUnsafeArgumentsError(`[decidio] ${p} has unsupported type ${typeof v}.`);
129
105
  };
130
106
  walk(value, path);
131
107
  }
108
+ export function assertSafeTransport(url, hasCredential) {
109
+ if (!hasCredential)
110
+ return;
111
+ if (/^http:\/\//i.test(url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(url)) {
112
+ 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.`);
113
+ }
114
+ }
package/openapi.yaml CHANGED
@@ -5,7 +5,7 @@
5
5
  openapi: 3.1.0
6
6
  info:
7
7
  title: Decidio Agent Gate
8
- version: 0.1.9
8
+ version: 0.1.10
9
9
  description: >
10
10
  Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
11
11
  receive the signed resume webhook when a routed action is decided. Every outcome seals a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decidio/sdk",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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",
@@ -77,20 +77,14 @@
77
77
  },
78
78
  "scripts": {
79
79
  "typecheck": "tsc --noEmit",
80
- "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
80
+ "build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
81
81
  "prepublishOnly": "npm run typecheck && npm run build"
82
82
  },
83
83
  "dependencies": {
84
- "@decidio/verify": "0.1.8"
84
+ "@decidio/verify": "0.1.9"
85
85
  },
86
86
  "author": "OmniTwin Technologies Inc.",
87
87
  "bugs": {
88
88
  "url": "https://decidioai.com/support/"
89
- },
90
- "peerDependencies": {
91
- "inngest": ">=3 <4",
92
- "@langchain/langgraph": ">=0.2 <2",
93
- "@temporalio/workflow": ">=1 <2",
94
- "@openai/agents": ">=0.0.1 <2"
95
89
  }
96
90
  }