@decidio/sdk 0.1.8 → 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/guard.js CHANGED
@@ -9,7 +9,7 @@ export class Guard {
9
9
  }
10
10
  static fromEnv() {
11
11
  return new Guard({
12
- apiUrl: process.env.DECIDIO_API_URL ?? "http://localhost:4000",
12
+ apiUrl: process.env.DECIDIO_API_URL ?? "",
13
13
  agentId: process.env.DECIDIO_AGENT_ID ?? "agent",
14
14
  apiToken: process.env.DECIDIO_API_TOKEN,
15
15
  sourceSystem: process.env.DECIDIO_SOURCE_SYSTEM,
@@ -29,6 +29,7 @@ export class Guard {
29
29
  return this.ensureResume().worker(opts);
30
30
  }
31
31
  ensureResume() {
32
+ this.assertConfigured();
32
33
  if (!this.resume) {
33
34
  const c = this.config;
34
35
  this.resume = createDecidioResume({
@@ -40,14 +41,24 @@ export class Guard {
40
41
  }
41
42
  return this.resume;
42
43
  }
44
+ assertConfigured() {
45
+ if (!this.config.apiUrl)
46
+ throw new Error("[decidio] no gate URL configured — set DECIDIO_API_URL (the hosted sandbox is https://decidio-api.onrender.com) or run `npx @decidio/sdk init`, then start your process with `node --env-file=.env`. Nothing was routed or executed.");
47
+ }
43
48
  protect(fn, describe, opts) {
49
+ this.assertConfigured();
44
50
  if (opts?.adapter && opts.adapter !== "generic") {
45
- throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via the @decidio/sdk/${opts.adapter} subpath (it needs the engine handle). See that adapter's exports.`);
51
+ const REAL = ["langgraph", "openai", "temporal", "inngest"];
52
+ if (REAL.includes(opts.adapter)) {
53
+ throw new Error(`[decidio] adapter "${opts.adapter}" must be bound via the @decidio/sdk/${opts.adapter} subpath (it needs the engine handle). See that adapter's exports.`);
54
+ }
55
+ throw new Error(`[decidio] unknown adapter "${opts.adapter}" — the available engine adapters are ${REAL.map((a) => `"${a}"`).join(", ")} (each via its @decidio/sdk/<name> subpath), or omit the option for the core gate.`);
46
56
  }
47
57
  const c = this.config;
48
58
  const base = {
49
59
  apiUrl: c.apiUrl, agentId: c.agentId, apiToken: c.apiToken,
50
60
  sourceSystem: c.sourceSystem, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId, appUrl: c.appUrl, fetchImpl: c.fetchImpl,
61
+ pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
51
62
  };
52
63
  if ((opts?.mode ?? "durable") === "blocking") {
53
64
  console.warn("[decidio] WARNING: blocking wait — only safe for short, watched approvals. " +
package/dist/index.d.ts CHANGED
@@ -1,27 +1,54 @@
1
1
  import type { ResumeController } from "./resume.js";
2
- export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
2
+ export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
3
3
  export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
4
4
  export { guard, Guard } from "./guard.js";
5
5
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
6
- export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
6
+ export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
7
7
  export type { RequesterIdentity } from "./signing.js";
8
8
  export interface GuardConfig {
9
+ /** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
9
10
  apiUrl: string;
11
+ /** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
10
12
  agentId: string;
13
+ /** Bearer token for the REST floor (a trusted, already-authenticated internal
14
+ * caller). Lives ONLY in the agent host's environment — never in client code. */
11
15
  apiToken?: string;
16
+ /** Source system label sealed onto the receipt (e.g. "salesforce"). */
12
17
  sourceSystem?: string;
18
+ /** The agent's PRIVATE signing key (PKCS8 base64), generated locally by `init` and held
19
+ * ONLY in the agent host's env as DECIDIO_AGENT_KEY. When set (with `agentDid`), the SDK
20
+ * signs a request-bound identity proof on each authorize so the floor PROVES the requester
21
+ * instead of trusting the label — required for a registered agent to auto-approve. */
13
22
  agentKey?: string;
23
+ /** The agent's PUBLIC did:key (the one registered with Decidio). Pairs with `agentKey`. */
14
24
  agentDid?: string;
25
+ /** The workspace this agent acts in (DECIDIO_WORKSPACE_ID, from the owner's agent-token mint).
26
+ * Bound into the signed proof (D-B3.6) so a captured proof can't be replayed in another tenant.
27
+ * For a tester's per-workspace agent token this is the tester's workspace id. */
15
28
  workspaceId?: string;
29
+ /** Poll cadence + ceiling while a routed action awaits human approval. Used ONLY
30
+ * by the blocking transport (no `resume` configured). */
16
31
  pollIntervalMs?: number;
17
32
  pollTimeoutMs?: number;
33
+ /** Optional async notifier so a long human wait surfaces in your logs/UI. */
18
34
  onPending?: (info: {
19
35
  decisionId: string;
20
36
  receiptId: string;
21
37
  url: string | null;
22
38
  }) => void;
39
+ /** Base URL of the Decidio app (DECIDIO_APP_URL). When set, a routed action can say WHERE to
40
+ * approve it — `<appUrl>/d/<decisionId>` opens that decision directly, on web or on the phone
41
+ * build. This link used to be built only inside our own example, so a developer who pasted the
42
+ * env block set DECIDIO_APP_URL and nothing ever read it. */
23
43
  appUrl?: string;
44
+ /** Durable async resume. When set, a routed action SUSPENDS (parks its args in the
45
+ * store, throws DecidioSuspendedError) instead of blocking — the process may exit
46
+ * and resume later via the controller's webhook handler or poll worker. This is
47
+ * the production transport for real (minutes-to-days) human approvals. */
24
48
  resume?: ResumeController;
49
+ /** Callback URL Decidio POSTs the verdict to on approval (webhook transport).
50
+ * Sent on /agent/authorize so the decision carries it. Falls back to
51
+ * resume.resumeUrl. Omit when using the poll worker. */
25
52
  resumeUrl?: string;
26
53
  fetchImpl?: typeof fetch;
27
54
  }
@@ -29,6 +56,9 @@ export interface ApprovalContext {
29
56
  action: string;
30
57
  amount: number;
31
58
  scope?: string;
59
+ /** Optional human-facing DETAILS of the action (e.g. { Account, "Lead Source", Stage }) shown to
60
+ * the approver as "what you're approving". Display-only — the policy decides on `amount`/`scope`,
61
+ * never these. Residual-safe tokenized server-side before persistence. */
32
62
  context?: Record<string, string | number>;
33
63
  }
34
64
  export declare class DecidioBlockedError extends Error {
@@ -45,6 +75,13 @@ export declare class DecidioTimeoutError extends Error {
45
75
  decisionId: string;
46
76
  constructor(decisionId: string);
47
77
  }
78
+ /** A transport failure with GUIDANCE instead of a raw fetch stack. Carries the endpoint, the
79
+ * operation, the underlying code, and the safety state a caller cares about most: whether
80
+ * anything could have executed. */
81
+ /** The workspace's governed-action allowance is exhausted (HTTP 429, code AGENT_RATE). The
82
+ * server says exactly how much and until when — `limit`, `remaining` and `resetAt` relay its
83
+ * answer verbatim so automation never has to parse a message. Nothing was routed, recorded,
84
+ * or executed; retry after `resetAt`. */
48
85
  export declare class DecidioRateLimitError extends Error {
49
86
  limit: number | undefined;
50
87
  remaining: number | undefined;
@@ -57,5 +94,12 @@ export declare class DecidioNetworkError extends Error {
57
94
  code: string | undefined;
58
95
  constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
59
96
  }
97
+ /**
98
+ * Wrap an async action behind the Decidio gate. `describe` extracts the approval
99
+ * context (action, amount, scope) from the call arguments. Returns a function with
100
+ * the SAME signature as `fn` — drop-in.
101
+ */
102
+ /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
103
+ * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
60
104
  export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
61
105
  export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig): (...args: A) => Promise<R>;
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { DecidioSuspendedError } from "./resume.js";
2
- import { signBoundProof, signConfirm } from "./signing.js";
3
- export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
1
+ import { DecidioSuspendedError, assertConfirmRecorded } from "./resume.js";
2
+ import { signBoundProof, signConfirm, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
3
+ export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
4
4
  export { guard, Guard } from "./guard.js";
5
- export { generateKeypair, signBoundProof, signConfirm, canonicalize } from "./signing.js";
5
+ export { generateKeypair, signBoundProof, signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
6
6
  export class DecidioBlockedError extends Error {
7
7
  reason;
8
8
  decisionId;
@@ -74,6 +74,7 @@ async function reach(f, url, op, init) {
74
74
  }
75
75
  async function post(cfg, path, body) {
76
76
  const f = cfg.fetchImpl ?? fetch;
77
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
77
78
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
78
79
  method: "POST",
79
80
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
@@ -89,6 +90,7 @@ async function post(cfg, path, body) {
89
90
  }
90
91
  async function get(cfg, path) {
91
92
  const f = cfg.fetchImpl ?? fetch;
93
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
92
94
  const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${path}`, {
93
95
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
94
96
  });
@@ -106,10 +108,22 @@ export function withApproval(fn, describe, config) {
106
108
  const pollTimeout = config.pollTimeoutMs ?? 600_000;
107
109
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
108
110
  return async (...args) => {
111
+ try {
112
+ assertJsonSafe(args, "args");
113
+ }
114
+ catch (e) {
115
+ throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} The decision was NOT routed; nothing reached Decidio.`);
116
+ }
109
117
  const ctx = describe(...args);
110
118
  const requesterIdentity = (config.agentKey && config.agentDid)
111
119
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
112
120
  : undefined;
121
+ try {
122
+ assertJsonSafe(ctx, "describe(...)");
123
+ }
124
+ catch (e) {
125
+ throw new DecidioUnsafeArgumentsError(`${e?.message ?? e} The decision was NOT routed; nothing reached Decidio.`);
126
+ }
113
127
  const auth = await post(config, "/agent/authorize", {
114
128
  requester: config.agentId, action: ctx.action, amount: ctx.amount,
115
129
  scope: ctx.scope, sourceSystem: config.sourceSystem,
@@ -126,7 +140,7 @@ export function withApproval(fn, describe, config) {
126
140
  if (config.onPending)
127
141
  config.onPending({ decisionId: auth.decisionId, receiptId: auth.receiptId, url: approvalUrl });
128
142
  else
129
- console.log(`[decidio] ${ctx.action} is awaiting approval (decision ${auth.decisionId}) — approve it ${approvalUrl ? `at ${approvalUrl}` : `in your Decidio queue, or: npx @decidio/sdk approvals approve ${auth.decisionId}`}`);
143
+ console.error(`[decidio] ${ctx.action} is awaiting approval (decision ${auth.decisionId}) — approve it ${approvalUrl ? `at ${approvalUrl}` : `in your Decidio queue, or: npx @decidio/sdk approvals approve ${auth.decisionId}`}`);
130
144
  if (config.resume) {
131
145
  config.resume.agentId ??= config.agentId;
132
146
  config.resume.agentKey ??= config.agentKey;
@@ -157,12 +171,13 @@ export function withApproval(fn, describe, config) {
157
171
  const result = await fn(...args);
158
172
  try {
159
173
  const confirmIdentity = signConfirm(auth.decisionId, config);
160
- await post(config, "/agent/confirm", {
174
+ const confirmBody = await post(config, "/agent/confirm", {
161
175
  decisionId: auth.decisionId,
162
176
  capturedResponse: result ?? null,
163
177
  source: "wrapper",
164
178
  ...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
165
179
  });
180
+ assertConfirmRecorded(confirmBody, auth.decisionId, !!confirmIdentity);
166
181
  if (!confirmIdentity)
167
182
  console.warn(`[decidio] execution evidence for ${auth.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 (npx @decidio/sdk init writes them) to earn application_confirmed.`);
168
183
  }
package/dist/inngest.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import type { RequestListener } from "node:http";
2
2
  import { verifyResumeSignature } from "./resume.js";
3
3
  import type { GuardConfig, ApprovalContext } from "./index.js";
4
+ /** The Inngest event Decidio's verdict is translated into. The waiting function
5
+ * correlates on `data.decisionId`. */
4
6
  export declare const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
7
+ /** The minimal slices of the Inngest step + client APIs we use, typed structurally so
8
+ * this file doesn't force an `inngest` type dependency on the core build. */
5
9
  export interface InngestStepLike {
6
10
  run<T>(id: string, fn: () => Promise<T>): Promise<T>;
7
11
  waitForEvent(id: string, opts: {
@@ -25,6 +29,16 @@ export type GateStepOutcome<R> = {
25
29
  decisionId: string;
26
30
  result?: R;
27
31
  };
32
+ /**
33
+ * Gate an action INSIDE an Inngest function, with durable suspend on routing.
34
+ * • proceed → run the action (as a step) + confirm.
35
+ * • route → `step.waitForEvent` durably suspends until Decidio's resume event for
36
+ * THIS decision arrives; approved → run + confirm; rejected → status.
37
+ * • block → return blocked (the action never runs).
38
+ * The action runs in YOUR process with YOUR credentials — Decidio only signals.
39
+ * `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
40
+ * so Decidio's webhook is translated into the event this step waits on.
41
+ */
28
42
  export declare function decidioGateStep<R>(args: {
29
43
  step: InngestStepLike;
30
44
  config: GuardConfig;
@@ -32,6 +46,12 @@ export declare function decidioGateStep<R>(args: {
32
46
  run: () => Promise<R>;
33
47
  timeout?: string;
34
48
  }): Promise<GateStepOutcome<R>>;
49
+ /**
50
+ * Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
51
+ * `resumeUrl`. It verifies Decidio's signed resume webhook (same HMAC as the core
52
+ * handler) and emits `DECIDIO_RESUME_EVENT` into Inngest, waking the suspended
53
+ * `decidioGateStep`. Fails CLOSED without a configured secret (unless allowUnsigned).
54
+ */
35
55
  export declare function createInngestResumeBridge(opts: {
36
56
  inngest: InngestClientLike;
37
57
  webhookSecret?: string;
package/dist/resume.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { RequestListener } from "node:http";
2
+ /** A routed action parked on the agent side, awaiting human approval. The `args`
3
+ * are the original call arguments, replayed verbatim when the action resumes. */
2
4
  export interface PendingAction {
3
5
  decisionId: string;
4
6
  receiptId?: string;
@@ -7,17 +9,37 @@ export interface PendingAction {
7
9
  scope?: string;
8
10
  args: unknown[];
9
11
  createdAt: string;
12
+ /** Integrity binding over {decisionId, action, amount, scope, args} — without it the parked
13
+ * payload would be mutable between routing and execution. ed25519 = detached
14
+ * signature by the agent's key; hmac = keyed by the webhook secret. Verified before ANY
15
+ * re-execution; a mismatch REFUSES and leaves the file for inspection. When the controller
16
+ * holds an agent key, a MISSING signature is also refused (a stripped signature is
17
+ * indistinguishable from tampering) — including entries parked by pre-0.1.6 versions:
18
+ * drain pending approvals before upgrading, or delete + re-run them. Only a keyless
19
+ * controller executes unbound entries (with a loud warning). Honest scope: this defends
20
+ * against modification by anything lacking the agent's key material — a fully compromised
21
+ * agent host can bypass the guard entirely and is out of client-side scope (server-sealed
22
+ * args binding is the receipt-v2 design). */
10
23
  argsSig?: {
11
24
  kind: "ed25519" | "hmac";
12
25
  sig: string;
13
26
  };
14
27
  }
28
+ /** Where suspended actions live between request and approval. The default is a
29
+ * file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
30
+ * Decidio never sees this. */
15
31
  export interface PendingStore {
16
32
  put(p: PendingAction): Promise<void>;
17
33
  get(decisionId: string): Promise<PendingAction | null>;
18
34
  delete(decisionId: string): Promise<void>;
19
35
  list(): Promise<PendingAction[]>;
36
+ /** Atomically take ownership of a parked action for execution. Returns true if THIS
37
+ * caller claimed it, false if it's already claimed/gone — the cross-process single-use
38
+ * guarantee (a production store backs this with rename/SETNX/SELECT-FOR-UPDATE). A
39
+ * claimed entry is excluded from list(). Optional: if absent, the controller falls
40
+ * back to an in-process guard (same-process only). */
20
41
  claim?(decisionId: string): Promise<boolean>;
42
+ /** Undo a claim so a failed execution is retryable. */
21
43
  release?(decisionId: string): Promise<void>;
22
44
  }
23
45
  export declare class MemoryPendingStore implements PendingStore {
@@ -30,6 +52,11 @@ export declare class MemoryPendingStore implements PendingStore {
30
52
  claim(id: string): Promise<boolean>;
31
53
  release(id: string): Promise<void>;
32
54
  }
55
+ /** File-backed store so a COLD process (the original requester long gone) can still
56
+ * resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
57
+ * renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
58
+ * sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
59
+ * TTL recovery — tracked in the backlog.) */
33
60
  export declare class FilePendingStore implements PendingStore {
34
61
  private dir;
35
62
  constructor(dir: string);
@@ -43,6 +70,10 @@ export declare class FilePendingStore implements PendingStore {
43
70
  claim(id: string): Promise<boolean>;
44
71
  release(id: string): Promise<void>;
45
72
  }
73
+ /** Thrown by a `withApproval`-wrapped call when the action is routed and configured
74
+ * for async resume: the action is now SUSPENDED (parked in the store), not failed.
75
+ * The caller can stop here — the process may even exit; the resume transport will
76
+ * complete it later. */
46
77
  export declare class DecidioSuspendedError extends Error {
47
78
  decisionId: string;
48
79
  action: string;
@@ -55,31 +86,71 @@ export interface ResumeOutcome {
55
86
  action?: string;
56
87
  result?: unknown;
57
88
  reason?: string;
89
+ /** executed only: did the execution-evidence report reach Decidio AND come back with a recorded
90
+ * tier? `status: "executed"` describes the LOCAL run; this describes the record — a lost
91
+ * confirmation must never read as recorded evidence. */
92
+ confirmed?: boolean;
93
+ /** executed only: the evidence tier the server says it recorded (e.g. application_confirmed). */
94
+ evidenceTier?: string;
58
95
  }
96
+ /** Minimal config the controller needs to confirm outcomes + poll status. Shared
97
+ * with GuardConfig (apiUrl/apiToken). */
59
98
  export interface ResumeConfig {
60
99
  apiUrl: string;
61
100
  apiToken?: string;
101
+ /** Agent identity for SIGNING the confirm call on resume (so the durable path can earn the
102
+ * application_confirmed tier, not just the inline path). Default from DECIDIO_AGENT_ID/
103
+ * _KEY/_DID env when unset — a cold resume process that has the env signs automatically. */
62
104
  agentId?: string;
63
105
  agentKey?: string;
64
106
  agentDid?: string;
107
+ /** The workspace the agent acts in (DECIDIO_WORKSPACE_ID) — bound into the cold-resume confirm
108
+ * proof (D-B3.6) so it's tenant-scoped too. Default from env when unset. */
65
109
  workspaceId?: string;
110
+ /** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
111
+ * the API token if unset. Verified before any re-execution. */
66
112
  webhookSecret?: string;
113
+ /** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
114
+ * with no secret and this unset, the webhook handler rejects every call (fail
115
+ * closed) so a misconfigured agent can't be driven by forged callbacks. Setting it
116
+ * true REMOVES webhook authenticity — anyone who can reach the endpoint can signal
117
+ * work — and the controller says so loudly at startup (an absolute fail-closed
118
+ * guarantee must disclose its opt-out). Only behind a trusted network boundary
119
+ * (e.g. mTLS / private mesh), never on an internet-reachable endpoint. */
67
120
  allowUnsigned?: boolean;
121
+ /** The callback URL Decidio should POST the verdict to (webhook transport). Sent
122
+ * on /agent/authorize so the decision carries it; omit when using the poll worker. */
68
123
  resumeUrl?: string;
124
+ /** Fired after every terminal resolution (executed / rejected / blocked / no_handler),
125
+ * via either transport. Use it to log, close a listener, or exit a CLI. */
69
126
  onResolved?: (o: ResumeOutcome) => void;
70
127
  fetchImpl?: typeof fetch;
71
128
  }
129
+ /** Re-execute the agent's own fn on approval, confirm the captured outcome to
130
+ * Decidio, and clear the parked entry. Bound to a store + a registry of raw fns. */
72
131
  export interface ResumeController extends ResumeConfig {
73
132
  store: PendingStore;
133
+ /** Register a RAW fn under its action name so a (possibly cold) resume can replay
134
+ * it. `withApproval` auto-registers on the hot path; a cold worker/service should
135
+ * register at startup so it can resume actions it never originated. */
74
136
  register(action: string, fn: (...args: any[]) => Promise<any>): void;
137
+ /** Park a routed action WITH its integrity binding (prefer this over store.put — the
138
+ * binding is what lets resolve() refuse a tampered payload). */
75
139
  park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
140
+ /** Drive a parked decision to completion once a verdict is known. Idempotent:
141
+ * a second call after success finds nothing parked → "unknown" (no double-write). */
76
142
  resolve(input: {
77
143
  decisionId: string;
78
144
  verdict: "approved" | "rejected" | "blocked";
79
145
  reason?: string;
80
146
  }): Promise<ResumeOutcome>;
147
+ /** node:http listener for Decidio's signed approval webhook (Express/Fastify/node). */
81
148
  webhookHandler(): RequestListener;
149
+ /** Web-Fetch handler (Request → Response) for Next.js route handlers, Hono,
150
+ * Cloudflare Workers, Bun, Deno. Same fail-closed signature check. */
82
151
  fetchHandler(): (req: Request) => Promise<Response>;
152
+ /** Poll-based resume for hosts with no inbound URL (CLI/laptop). A long-lived
153
+ * watcher — this is a durable WORKER, not the request path blocking. */
83
154
  worker(opts?: {
84
155
  intervalMs?: number;
85
156
  timeoutMs?: number;
@@ -89,7 +160,24 @@ export interface ResumeController extends ResumeConfig {
89
160
  };
90
161
  }
91
162
  declare function sign(secret: string, body: string): string;
163
+ /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
164
+ * Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
92
165
  export declare function verifyResumeSignature(secret: string, body: string, header: string | undefined): boolean;
166
+ /** The ONE confirm-body rule, shared by every confirm door (resume, inline withApproval,
167
+ * adapter gate). Transport success is not confirmation: a 2xx whose body carries no recorded
168
+ * tier is a FAILED confirm; a SIGNED confirm coming back below application_confirmed is
169
+ * disclosed, never silent. Returns the recorded tier or throws. */
170
+ export declare function assertConfirmRecorded(body: any, decisionId: string, signed: boolean): string;
171
+ /**
172
+ * Build the resume controller. Spread it into your GuardConfig as `resume`, then
173
+ * either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
174
+ *
175
+ * const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
176
+ * const guard = { apiUrl, agentId, apiToken, resume };
177
+ * const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
178
+ * // production: http.createServer(resume.webhookHandler()).listen(4100)
179
+ * // or CLI: resume.worker()
180
+ */
93
181
  export declare function createDecidioResume(opts: ResumeConfig & {
94
182
  store: PendingStore;
95
183
  }): ResumeController;
package/dist/resume.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { signConfirm, canonicalize } from "./signing.js";
4
+ import { userInfo } from "node:os";
5
+ import { execFileSync } from "node:child_process";
6
+ import { signConfirm, canonicalize, assertJsonSafe, assertSafeTransport, DecidioUnsafeArgumentsError } from "./signing.js";
5
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");
6
8
  function signArgs(p, agentKey, webhookSecret) {
7
9
  const bytes = argsBindingBytes(p);
@@ -53,6 +55,18 @@ export class FilePendingStore {
53
55
  constructor(dir) {
54
56
  this.dir = dir;
55
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
+ }
56
70
  }
57
71
  key(id) { return id.replace(/[^\w.-]/g, "_"); }
58
72
  path(id) { return join(this.dir, `${this.key(id)}.json`); }
@@ -118,20 +132,34 @@ export function verifyResumeSignature(secret, body, header) {
118
132
  const a = Buffer.from(expected), b = Buffer.from(header);
119
133
  return a.length === b.length && timingSafeEqual(a, b);
120
134
  }
135
+ export function assertConfirmRecorded(body, decisionId, signed) {
136
+ if (!body || body.ok !== true || typeof body.confirmation !== "string") {
137
+ throw new Error(`confirm answered 2xx but reported no evidence tier — treating the confirmation as NOT recorded`);
138
+ }
139
+ if (signed && body.confirmation !== "application_confirmed") {
140
+ console.warn(`[decidio] execution evidence for ${decisionId} was recorded as "${body.confirmation}", not application_confirmed, despite a signed report — the server did not accept the identity proof (key/DID/registration mismatch?). Run \`npx @decidio/sdk doctor\` and re-init if this persists.`);
141
+ }
142
+ return body.confirmation;
143
+ }
121
144
  async function confirm(cfg, decisionId, capturedResponse) {
122
145
  const f = cfg.fetchImpl ?? fetch;
146
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
123
147
  const requesterIdentity = signConfirm(decisionId, cfg);
124
148
  if (!requesterIdentity)
125
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.`);
126
- await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
150
+ const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
127
151
  method: "POST",
128
152
  headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
129
153
  body: JSON.stringify({ decisionId, capturedResponse: capturedResponse ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
130
- }).then((r) => { if (!r.ok)
131
- throw new Error(`confirm ${r.status}`); });
154
+ });
155
+ if (!r.ok)
156
+ throw new Error(`confirm ${r.status}`);
157
+ const body = await r.json().catch(() => null);
158
+ return assertConfirmRecorded(body, decisionId, !!requesterIdentity);
132
159
  }
133
160
  async function statusOf(cfg, decisionId) {
134
161
  const f = cfg.fetchImpl ?? fetch;
162
+ assertSafeTransport(cfg.apiUrl, !!cfg.apiToken);
135
163
  const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
136
164
  headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
137
165
  });
@@ -143,6 +171,15 @@ export function createDecidioResume(opts) {
143
171
  const registry = new Map();
144
172
  const inFlight = new Set();
145
173
  const secret = opts.webhookSecret ?? opts.apiToken ?? "";
174
+ if (opts.allowUnsigned)
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.");
176
+ const refusalWarned = new Set();
177
+ const refuseOnce = (decisionId, msg) => {
178
+ if (refusalWarned.has(decisionId))
179
+ return;
180
+ refusalWarned.add(decisionId);
181
+ console.error(msg + " (This prints once — the entry stays parked until you act.)");
182
+ };
146
183
  const ctrl = {
147
184
  ...opts,
148
185
  agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
@@ -158,6 +195,12 @@ export function createDecidioResume(opts) {
158
195
  registry.set(action, fn);
159
196
  },
160
197
  async park(p) {
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
+ }
161
204
  const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
162
205
  if (!argsSig)
163
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.`);
@@ -180,21 +223,21 @@ export function createDecidioResume(opts) {
180
223
  if (verdict !== "approved" && verdict !== "auto_approved")
181
224
  return { status: "unknown", decisionId };
182
225
  if (pending.decisionId !== decisionId) {
183
- console.error(`[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.`);
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.`);
184
227
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
185
228
  }
186
229
  const strictBinding = !!ctrl.agentKey;
187
230
  const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
188
231
  if (integrity === "mismatch") {
189
- console.error(`[decidio] REFUSING to execute ${decisionId}: the parked payload FAILED its integrity check — the arguments on disk are not the arguments that were routed for approval. The pending file is untouched for inspection; delete it and re-run the action to request a fresh approval.`);
232
+ refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload FAILED its integrity check — the arguments on disk are not the arguments that were routed for approval. The pending file is untouched for inspection; delete it and re-run the action to request a fresh approval.`);
190
233
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload integrity check failed" });
191
234
  }
192
235
  if (integrity === "unverifiable") {
193
- console.error(`[decidio] REFUSING to execute ${decisionId}: the parked payload is signed but this process lacks the key material to verify it (DECIDIO_AGENT_KEY / DECIDIO_WEBHOOK_SECRET). Restore the agent's env and retry.`);
236
+ refuseOnce(decisionId, `[decidio] REFUSING to execute ${decisionId}: the parked payload is signed but this process lacks the key material to verify it (DECIDIO_AGENT_KEY / DECIDIO_WEBHOOK_SECRET). Restore the agent's env and retry.`);
194
237
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
195
238
  }
196
239
  if (integrity === "unbound" && strictBinding) {
197
- console.error(`[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.`);
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.`);
198
241
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
199
242
  }
200
243
  if (integrity === "unbound")
@@ -215,8 +258,9 @@ export function createDecidioResume(opts) {
215
258
  try {
216
259
  const result = await fn(...pending.args);
217
260
  let confirmed = true;
261
+ let tier;
218
262
  try {
219
- await confirm(ctrl, decisionId, result);
263
+ tier = await confirm(ctrl, decisionId, result);
220
264
  }
221
265
  catch (e) {
222
266
  confirmed = false;
@@ -231,7 +275,7 @@ export function createDecidioResume(opts) {
231
275
  else
232
276
  console.warn(` This store provides no claim() primitive, so the entry was DELETED to guarantee the action cannot execute twice. ${decisionId} executed, but its confirmation never reached Decidio — re-confirm it from your own records.`);
233
277
  }
234
- return finish({ status: "executed", decisionId, action: pending.action, result });
278
+ return finish({ status: "executed", decisionId, action: pending.action, result, confirmed, ...(tier ? { evidenceTier: tier } : {}) });
235
279
  }
236
280
  catch (e) {
237
281
  if (ctrl.store.release)
@@ -320,6 +364,14 @@ export function createDecidioResume(opts) {
320
364
  continue;
321
365
  try {
322
366
  const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
367
+ if (outcome.status === "executed" && outcome.confirmed)
368
+ console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, evidence recorded as ${outcome.evidenceTier ?? "confirmed"}.`);
369
+ else if (outcome.status === "executed")
370
+ console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution evidence did NOT reach Decidio — see the warning above.`);
371
+ else if (outcome.status === "rejected")
372
+ console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
373
+ else if (outcome.status === "blocked")
374
+ console.error(`[decidio] resumed ${p.decisionId}: BLOCKED by policy — the parked "${p.action}" was discarded, nothing executed.`);
323
375
  workerOpts?.onResolved?.(outcome);
324
376
  }
325
377
  catch (e) {
package/dist/signing.d.ts CHANGED
@@ -1,4 +1,8 @@
1
+ /** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
1
2
  export declare function canonicalize(v: unknown): string;
3
+ /** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
4
+ * and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
5
+ * it never goes back to Decidio). */
2
6
  export declare function generateKeypair(): {
3
7
  did: string;
4
8
  privateKeyBase64: string;
@@ -17,12 +21,20 @@ export interface RequesterIdentity {
17
21
  signature: string;
18
22
  };
19
23
  }
24
+ /** Sign the CONFIRM call (bound to {action:"confirm", scope:decisionId}) so the gate can
25
+ * prove the caller is the receipt's original requester before granting the wrapper tier
26
+ * (application_confirmed). Returns undefined when no signing key is configured (the server
27
+ * then caps the report at agent_asserted). Used by EVERY confirm path — inline, durable
28
+ * resume, and the engine adapters — so the production paths confirm with a proof too. Also
29
+ * binds `workspaceId` (D-B3.6) when configured, so the confirm proof is tenant-scoped too. */
20
30
  export declare function signConfirm(decisionId: string, cfg: {
21
31
  agentId?: string;
22
32
  agentKey?: string;
23
33
  agentDid?: string;
24
34
  workspaceId?: string;
25
35
  }): RequesterIdentity | undefined;
36
+ /** Sign a request-bound identity proof. The returned object is sent verbatim as the
37
+ * `requesterIdentity` field of /agent/authorize. */
26
38
  export declare function signBoundProof(privateKeyBase64: string, fields: {
27
39
  agentId: string;
28
40
  did: string;
@@ -34,3 +46,21 @@ export declare function signBoundProof(privateKeyBase64: string, fields: {
34
46
  now?: number;
35
47
  nonce?: string;
36
48
  }): RequesterIdentity;
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
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
+ }
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;