@decidio/sdk 0.1.4 → 0.1.6

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/index.js CHANGED
@@ -1,32 +1,3 @@
1
- // @decidio/sdk — the one-line approval gate for AI-agent actions.
2
- //
3
- // import { guard } from "@decidio/sdk";
4
- // const createOpportunity = guard.protect(createOpportunityRaw, (a) => ({
5
- // action: "createOpportunity", amount: a.amount, scope: "Opportunity",
6
- // }));
7
- //
8
- // NOTE: `guard` is the preconfigured SINGLETON (a Guard instance, see guard.ts). It is NOT a
9
- // GuardConfig, so it cannot be passed as withApproval's third argument — this comment used to
10
- // show exactly that, and the error propagated into the product's own copy-paste snippets.
11
- // `withApproval(fn, describe, config)` is the lower-level export and takes a config OBJECT.
12
- //
13
- // On call, the wrapper asks Decidio's gate to authorize the action. Decidio's
14
- // policy engine (Cedar / computeChain) returns proceed | route | block:
15
- // • proceed → the wrapped fn runs immediately (auto-approved under policy)
16
- // • route → the wrapper WAITS for a human to approve in Decidio's queue, then
17
- // runs the fn (or throws if rejected)
18
- // • block → the wrapper throws; the fn never runs
19
- // The wrapper is a LISTENER: it captures the real source-system response the wrapped
20
- // fn returns and reports it to Decidio, which seals an `application_confirmed`
21
- // Authority Receipt (Decidio minimizes + tokenizes before sealing — the immutable
22
- // seal never stores raw payloads). The agent executes its own action; Decidio
23
- // authorizes, records, and (optionally) independently verifies it. Decidio holds NO
24
- // write credentials for your system — only the authority decision and the record.
25
- //
26
- // Framework-agnostic by design: it wraps at the tool-call boundary, so it works with
27
- // any agent runtime (LangGraph, OpenAI Agents SDK, CrewAI, a raw tool loop) and any
28
- // action (Salesforce, M365, SAP, a DB write) — the wrapper doesn't care what the fn
29
- // does, only that it returns the source response.
30
1
  import { DecidioSuspendedError } from "./resume.js";
31
2
  import { signBoundProof, signConfirm } from "./signing.js";
32
3
  export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume } from "./resume.js";
@@ -61,10 +32,6 @@ export class DecidioTimeoutError extends Error {
61
32
  }
62
33
  }
63
34
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
64
- /** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
65
- * wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
66
- * endpoint, the operation, the underlying code, and the safety state a caller cares about most:
67
- * whether anything could have executed. */
68
35
  export class DecidioNetworkError extends Error {
69
36
  endpoint;
70
37
  op;
@@ -109,13 +76,6 @@ async function get(cfg, path) {
109
76
  throw new Error(`decidio ${path} ${res.status}`);
110
77
  return res.json();
111
78
  }
112
- /**
113
- * Wrap an async action behind the Decidio gate. `describe` extracts the approval
114
- * context (action, amount, scope) from the call arguments. Returns a function with
115
- * the SAME signature as `fn` — drop-in.
116
- */
117
- /** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
118
- * the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
119
79
  export function decisionUrl(appUrl, decisionId) {
120
80
  if (!appUrl)
121
81
  return null;
@@ -127,10 +87,6 @@ export function withApproval(fn, describe, config) {
127
87
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
128
88
  return async (...args) => {
129
89
  const ctx = describe(...args);
130
- // 1. ask the gate (carry the resume URL so a routed decision knows where to call back).
131
- // When a signing key is configured, sign a request-bound identity proof so the floor
132
- // can PROVE this agent rather than trust the `requester` label (registered agents must
133
- // sign to auto-approve). Unsigned still works for generic/non-agent callers.
134
90
  const requesterIdentity = (config.agentKey && config.agentDid)
135
91
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
136
92
  : undefined;
@@ -141,48 +97,28 @@ export function withApproval(fn, describe, config) {
141
97
  ...(requesterIdentity ? { requesterIdentity } : {}),
142
98
  ...(resumeUrl ? { resumeUrl } : {}),
143
99
  });
144
- // Allow-list, not deny-list: an unrecognised verdict means this SDK is older than the
145
- // server, and the safe reading of an answer you do not understand is NO. Without this, the
146
- // day a fourth verdict ships every deployed SDK falls through and executes it.
147
100
  if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
148
101
  throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
149
102
  if (auth.decision === "block")
150
103
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
151
- // 2. routed → either SUSPEND for async resume (durable) or BLOCK-poll (interactive)
152
104
  if (auth.decision === "route") {
153
- // ALWAYS hand back the decision id — an agent that suspends and says only "waiting" leaves
154
- // the operator hunting through a queue, and an agent that says NOTHING (the pre-0.1.2 bug:
155
- // the print was gated on appUrl, which init never writes) leaves them staring at a silent
156
- // terminal. The Codex acceptance run failed exactly this claim; the id line is unconditional
157
- // now, with the deep link when appUrl is configured and the CLI command when not.
158
105
  const approvalUrl = decisionUrl(config.appUrl, auth.decisionId);
159
106
  if (config.onPending)
160
107
  config.onPending({ decisionId: auth.decisionId, receiptId: auth.receiptId, url: approvalUrl });
161
108
  else
162
109
  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}`}`);
163
- // Durable transport: park the args agent-side, register the raw fn so a (cold)
164
- // resume can replay it, and SUSPEND. The process may exit now; the controller's
165
- // webhook handler or poll worker re-executes THIS fn when a human approves.
166
- // Decidio never executes — it only signals.
167
110
  if (config.resume) {
168
- // Propagate the agent's signing identity to the resume controller so the eventual
169
- // (possibly cold) confirm on resume is SIGNED too — earning application_confirmed on
170
- // the durable path, not just inline. (No-op if the controller already has its own.)
171
111
  config.resume.agentId ??= config.agentId;
172
112
  config.resume.agentKey ??= config.agentKey;
173
113
  config.resume.agentDid ??= config.agentDid;
174
114
  config.resume.register(ctx.action, fn);
175
- await config.resume.store.put({
115
+ await config.resume.park({
176
116
  decisionId: auth.decisionId, receiptId: auth.receiptId,
177
- action: ctx.action, amount: ctx.amount, scope: ctx.scope,
178
- args, createdAt: new Date().toISOString(),
117
+ action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
179
118
  });
180
119
  throw new DecidioSuspendedError(auth.decisionId, ctx.action);
181
120
  }
182
- // Interactive transport (no resume configured): block until decided. Only
183
- // appropriate for short, watched approvals (demos, synchronous tools).
184
121
  const deadline = Date.now() + pollTimeout;
185
- // eslint-disable-next-line no-constant-condition
186
122
  while (true) {
187
123
  const st = await get(config, `/agent/status/${encodeURIComponent(auth.decisionId)}`);
188
124
  if (st.state === "approved" || st.state === "auto_approved")
@@ -192,39 +128,25 @@ export function withApproval(fn, describe, config) {
192
128
  if (st.state === "blocked")
193
129
  throw new DecidioBlockedError(st.reason ?? "blocked", auth.decisionId);
194
130
  if (Date.now() > deadline) {
195
- // The founder-visible failure of blocking: the wait has a ceiling, and past it the
196
- // work is LOST — the decision stays open in Decidio but nothing is parked to resume.
197
- // Say so, say the limit, and say the durable way out.
198
131
  console.warn(`[decidio] blocking wait hit its ${Math.round(pollTimeout / 1000)}s limit — decision ${auth.decisionId} is still open in Decidio, but this process stopped waiting and nothing was executed. Re-run to request again, or use durable resume (guard.worker()) so an approval is never lost.`);
199
132
  throw new DecidioTimeoutError(auth.decisionId);
200
133
  }
201
134
  await sleep(pollInterval);
202
135
  }
203
136
  }
204
- // 3. authorized inline (proceed, or interactive route just approved) → run the
205
- // REAL action and capture the source response (listener)
206
137
  const result = await fn(...args);
207
- // 4. report the captured outcome → Decidio seals application_confirmed
208
- // (server minimizes + tokenizes the response before sealing). Best-effort:
209
- // a confirmation failure must not undo a successful business action.
210
138
  try {
211
- // Sign the confirmation too (bound to {action:"confirm", scope:decisionId}) so the gate
212
- // can PROVE this is the receipt's original requester before granting the wrapper tier
213
- // (application_confirmed). Without a key the report is capped at agent_asserted server-side.
214
139
  const confirmIdentity = signConfirm(auth.decisionId, config);
215
140
  await post(config, "/agent/confirm", {
216
141
  decisionId: auth.decisionId,
217
142
  capturedResponse: result ?? null,
218
- source: "wrapper", // the SDK captured the genuine response, not an agent self-report
143
+ source: "wrapper",
219
144
  ...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
220
145
  });
221
- // An UNSIGNED confirm is server-capped at the weaker agent_asserted tier. Say so at the
222
- // moment it happens — a silent evidence downgrade was round 3's acceptance FAIL.
223
146
  if (!confirmIdentity)
224
147
  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.`);
225
148
  }
226
149
  catch (e) {
227
- // surface but don't throw — the action already happened
228
150
  console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
229
151
  }
230
152
  return result;
package/dist/inngest.d.ts CHANGED
@@ -1,11 +1,7 @@
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`. */
6
4
  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. */
9
5
  export interface InngestStepLike {
10
6
  run<T>(id: string, fn: () => Promise<T>): Promise<T>;
11
7
  waitForEvent(id: string, opts: {
@@ -29,16 +25,6 @@ export type GateStepOutcome<R> = {
29
25
  decisionId: string;
30
26
  result?: R;
31
27
  };
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
- */
42
28
  export declare function decidioGateStep<R>(args: {
43
29
  step: InngestStepLike;
44
30
  config: GuardConfig;
@@ -46,12 +32,6 @@ export declare function decidioGateStep<R>(args: {
46
32
  run: () => Promise<R>;
47
33
  timeout?: string;
48
34
  }): 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
- */
55
35
  export declare function createInngestResumeBridge(opts: {
56
36
  inngest: InngestClientLike;
57
37
  webhookSecret?: string;
package/dist/inngest.js CHANGED
@@ -1,26 +1,12 @@
1
1
  import { verifyResumeSignature } from "./resume.js";
2
2
  import { signBoundProof, signConfirm } from "./signing.js";
3
- /** The Inngest event Decidio's verdict is translated into. The waiting function
4
- * correlates on `data.decisionId`. */
5
3
  export const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
6
- /**
7
- * Gate an action INSIDE an Inngest function, with durable suspend on routing.
8
- * • proceed → run the action (as a step) + confirm.
9
- * • route → `step.waitForEvent` durably suspends until Decidio's resume event for
10
- * THIS decision arrives; approved → run + confirm; rejected → status.
11
- * • block → return blocked (the action never runs).
12
- * The action runs in YOUR process with YOUR credentials — Decidio only signals.
13
- * `config.resumeUrl` must point at the bridge endpoint (createInngestResumeBridge),
14
- * so Decidio's webhook is translated into the event this step waits on.
15
- */
16
4
  export async function decidioGateStep(args) {
17
5
  const { step, config, ctx } = args;
18
6
  const base = config.apiUrl.replace(/\/$/, "");
19
7
  const f = config.fetchImpl ?? fetch;
20
8
  const authHeaders = { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) };
21
- // 1. authorize (memoized as a step, so a replay doesn't re-ask the gate)
22
9
  const auth = await step.run("decidio-authorize", async () => {
23
- // Sign the bound proof so an Inngest-path agent can prove its identity + auto-approve.
24
10
  const requesterIdentity = (config.agentKey && config.agentDid)
25
11
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
26
12
  : undefined;
@@ -40,9 +26,6 @@ export async function decidioGateStep(args) {
40
26
  if (auth.decision === "block")
41
27
  return { status: "blocked", decisionId: auth.decisionId };
42
28
  if (auth.decision === "route") {
43
- // 2. durably SUSPEND until the resume event for THIS decision arrives. Zero
44
- // compute while waiting; the worker/process may be recycled — Inngest resumes
45
- // from this checkpoint. `if` correlates the awaited event to this decisionId.
46
29
  const ev = await step.waitForEvent("decidio-await-approval", {
47
30
  event: DECIDIO_RESUME_EVENT,
48
31
  timeout: args.timeout ?? "30d",
@@ -56,8 +39,6 @@ export async function decidioGateStep(args) {
56
39
  if (verdict !== "approved" && verdict !== "auto_approved")
57
40
  return { status: "blocked", decisionId: auth.decisionId };
58
41
  }
59
- // 3. authorized → perform the agent's OWN action (a step), then confirm the captured
60
- // response so Decidio seals application_confirmed (never executes it itself).
61
42
  const result = await step.run("agent-action", args.run);
62
43
  await step.run("decidio-confirm", async () => {
63
44
  const requesterIdentity = signConfirm(auth.decisionId, config);
@@ -69,12 +50,6 @@ export async function decidioGateStep(args) {
69
50
  });
70
51
  return { status: "executed", decisionId: auth.decisionId, result };
71
52
  }
72
- /**
73
- * Decidio → Inngest bridge. Mount as an HTTP endpoint and register it as the agent's
74
- * `resumeUrl`. It verifies Decidio's signed resume webhook (same HMAC as the core
75
- * handler) and emits `DECIDIO_RESUME_EVENT` into Inngest, waking the suspended
76
- * `decidioGateStep`. Fails CLOSED without a configured secret (unless allowUnsigned).
77
- */
78
53
  export function createInngestResumeBridge(opts) {
79
54
  const secret = opts.webhookSecret ?? "";
80
55
  const eventName = opts.eventName ?? DECIDIO_RESUME_EVENT;
@@ -110,5 +85,4 @@ export function createInngestResumeBridge(opts) {
110
85
  });
111
86
  };
112
87
  }
113
- // Re-export the signature check so a host can verify the webhook out-of-band if needed.
114
88
  export { verifyResumeSignature };
package/dist/resume.d.ts CHANGED
@@ -1,6 +1,4 @@
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. */
4
2
  export interface PendingAction {
5
3
  decisionId: string;
6
4
  receiptId?: string;
@@ -9,22 +7,17 @@ export interface PendingAction {
9
7
  scope?: string;
10
8
  args: unknown[];
11
9
  createdAt: string;
10
+ argsSig?: {
11
+ kind: "ed25519" | "hmac";
12
+ sig: string;
13
+ };
12
14
  }
13
- /** Where suspended actions live between request and approval. The default is a
14
- * file store; swap in Redis/Postgres/your queue for production. AGENT-SIDE only —
15
- * Decidio never sees this. */
16
15
  export interface PendingStore {
17
16
  put(p: PendingAction): Promise<void>;
18
17
  get(decisionId: string): Promise<PendingAction | null>;
19
18
  delete(decisionId: string): Promise<void>;
20
19
  list(): Promise<PendingAction[]>;
21
- /** Atomically take ownership of a parked action for execution. Returns true if THIS
22
- * caller claimed it, false if it's already claimed/gone — the cross-process single-use
23
- * guarantee (a production store backs this with rename/SETNX/SELECT-FOR-UPDATE). A
24
- * claimed entry is excluded from list(). Optional: if absent, the controller falls
25
- * back to an in-process guard (same-process only). */
26
20
  claim?(decisionId: string): Promise<boolean>;
27
- /** Undo a claim so a failed execution is retryable. */
28
21
  release?(decisionId: string): Promise<void>;
29
22
  }
30
23
  export declare class MemoryPendingStore implements PendingStore {
@@ -37,11 +30,6 @@ export declare class MemoryPendingStore implements PendingStore {
37
30
  claim(id: string): Promise<boolean>;
38
31
  release(id: string): Promise<void>;
39
32
  }
40
- /** File-backed store so a COLD process (the original requester long gone) can still
41
- * resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
42
- * renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
43
- * sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
44
- * TTL recovery — tracked in the backlog.) */
45
33
  export declare class FilePendingStore implements PendingStore {
46
34
  private dir;
47
35
  constructor(dir: string);
@@ -55,10 +43,6 @@ export declare class FilePendingStore implements PendingStore {
55
43
  claim(id: string): Promise<boolean>;
56
44
  release(id: string): Promise<void>;
57
45
  }
58
- /** Thrown by a `withApproval`-wrapped call when the action is routed and configured
59
- * for async resume: the action is now SUSPENDED (parked in the store), not failed.
60
- * The caller can stop here — the process may even exit; the resume transport will
61
- * complete it later. */
62
46
  export declare class DecidioSuspendedError extends Error {
63
47
  decisionId: string;
64
48
  action: string;
@@ -72,58 +56,30 @@ export interface ResumeOutcome {
72
56
  result?: unknown;
73
57
  reason?: string;
74
58
  }
75
- /** Minimal config the controller needs to confirm outcomes + poll status. Shared
76
- * with GuardConfig (apiUrl/apiToken). */
77
59
  export interface ResumeConfig {
78
60
  apiUrl: string;
79
61
  apiToken?: string;
80
- /** Agent identity for SIGNING the confirm call on resume (so the durable path can earn the
81
- * application_confirmed tier, not just the inline path). Default from DECIDIO_AGENT_ID/
82
- * _KEY/_DID env when unset — a cold resume process that has the env signs automatically. */
83
62
  agentId?: string;
84
63
  agentKey?: string;
85
64
  agentDid?: string;
86
- /** The workspace the agent acts in (DECIDIO_WORKSPACE_ID) — bound into the cold-resume confirm
87
- * proof (D-B3.6) so it's tenant-scoped too. Default from env when unset. */
88
65
  workspaceId?: string;
89
- /** Shared secret Decidio signs the resume webhook with (HMAC-SHA256). Defaults to
90
- * the API token if unset. Verified before any re-execution. */
91
66
  webhookSecret?: string;
92
- /** Escape hatch to accept UNSIGNED resume webhooks (no secret). Off by default:
93
- * with no secret and this unset, the webhook handler rejects every call (fail
94
- * closed) so a misconfigured agent can't be driven by forged callbacks. Only set
95
- * true behind a trusted network boundary (e.g. mTLS / private mesh). */
96
67
  allowUnsigned?: boolean;
97
- /** The callback URL Decidio should POST the verdict to (webhook transport). Sent
98
- * on /agent/authorize so the decision carries it; omit when using the poll worker. */
99
68
  resumeUrl?: string;
100
- /** Fired after every terminal resolution (executed / rejected / blocked / no_handler),
101
- * via either transport. Use it to log, close a listener, or exit a CLI. */
102
69
  onResolved?: (o: ResumeOutcome) => void;
103
70
  fetchImpl?: typeof fetch;
104
71
  }
105
- /** Re-execute the agent's own fn on approval, confirm the captured outcome to
106
- * Decidio, and clear the parked entry. Bound to a store + a registry of raw fns. */
107
72
  export interface ResumeController extends ResumeConfig {
108
73
  store: PendingStore;
109
- /** Register a RAW fn under its action name so a (possibly cold) resume can replay
110
- * it. `withApproval` auto-registers on the hot path; a cold worker/service should
111
- * register at startup so it can resume actions it never originated. */
112
74
  register(action: string, fn: (...args: any[]) => Promise<any>): void;
113
- /** Drive a parked decision to completion once a verdict is known. Idempotent:
114
- * a second call after success finds nothing parked → "unknown" (no double-write). */
75
+ park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
115
76
  resolve(input: {
116
77
  decisionId: string;
117
78
  verdict: "approved" | "rejected" | "blocked";
118
79
  reason?: string;
119
80
  }): Promise<ResumeOutcome>;
120
- /** node:http listener for Decidio's signed approval webhook (Express/Fastify/node). */
121
81
  webhookHandler(): RequestListener;
122
- /** Web-Fetch handler (Request → Response) for Next.js route handlers, Hono,
123
- * Cloudflare Workers, Bun, Deno. Same fail-closed signature check. */
124
82
  fetchHandler(): (req: Request) => Promise<Response>;
125
- /** Poll-based resume for hosts with no inbound URL (CLI/laptop). A long-lived
126
- * watcher — this is a durable WORKER, not the request path blocking. */
127
83
  worker(opts?: {
128
84
  intervalMs?: number;
129
85
  timeoutMs?: number;
@@ -133,19 +89,7 @@ export interface ResumeController extends ResumeConfig {
133
89
  };
134
90
  }
135
91
  declare function sign(secret: string, body: string): string;
136
- /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
137
- * Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
138
92
  export declare function verifyResumeSignature(secret: string, body: string, header: string | undefined): boolean;
139
- /**
140
- * Build the resume controller. Spread it into your GuardConfig as `resume`, then
141
- * either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
142
- *
143
- * const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
144
- * const guard = { apiUrl, agentId, apiToken, resume };
145
- * const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
146
- * // production: http.createServer(resume.webhookHandler()).listen(4100)
147
- * // or CLI: resume.worker()
148
- */
149
93
  export declare function createDecidioResume(opts: ResumeConfig & {
150
94
  store: PendingStore;
151
95
  }): ResumeController;
package/dist/resume.js CHANGED
@@ -1,26 +1,42 @@
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
- import { createHmac, timingSafeEqual } from "node:crypto";
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 } from "./signing.js";
4
+ import { signConfirm, canonicalize } from "./signing.js";
5
+ 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
+ function signArgs(p, agentKey, webhookSecret) {
7
+ const bytes = argsBindingBytes(p);
8
+ if (agentKey) {
9
+ try {
10
+ const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
11
+ return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
12
+ }
13
+ catch { }
14
+ }
15
+ if (webhookSecret)
16
+ return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
17
+ return undefined;
18
+ }
19
+ function verifyArgs(p, agentKey, webhookSecret) {
20
+ if (!p.argsSig)
21
+ return "unbound";
22
+ const bytes = argsBindingBytes(p);
23
+ try {
24
+ if (p.argsSig.kind === "ed25519") {
25
+ if (!agentKey)
26
+ return "unverifiable";
27
+ const pub = createPublicKey(createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" }));
28
+ return edVerify(null, bytes, pub, Buffer.from(p.argsSig.sig, "base64url")) ? "ok" : "mismatch";
29
+ }
30
+ if (!webhookSecret)
31
+ return "unverifiable";
32
+ const expected = createHmac("sha256", webhookSecret).update(bytes).digest("hex");
33
+ const a = Buffer.from(expected), b = Buffer.from(p.argsSig.sig);
34
+ return a.length === b.length && timingSafeEqual(a, b) ? "ok" : "mismatch";
35
+ }
36
+ catch {
37
+ return "mismatch";
38
+ }
39
+ }
24
40
  export class MemoryPendingStore {
25
41
  m = new Map();
26
42
  claimed = new Set();
@@ -32,11 +48,6 @@ export class MemoryPendingStore {
32
48
  return false; this.claimed.add(id); return true; }
33
49
  async release(id) { this.claimed.delete(id); }
34
50
  }
35
- /** File-backed store so a COLD process (the original requester long gone) can still
36
- * resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
37
- * renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
38
- * sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
39
- * TTL recovery — tracked in the backlog.) */
40
51
  export class FilePendingStore {
41
52
  dir;
42
53
  constructor(dir) {
@@ -59,14 +70,14 @@ export class FilePendingStore {
59
70
  async list() {
60
71
  if (!existsSync(this.dir))
61
72
  return [];
62
- return readdirSync(this.dir).filter((f) => f.endsWith(".json")) // claimed (.claimed) excluded — in flight
73
+ return readdirSync(this.dir).filter((f) => f.endsWith(".json"))
63
74
  .map((f) => JSON.parse(readFileSync(join(this.dir, f), "utf8")));
64
75
  }
65
76
  async claim(id) {
66
77
  try {
67
78
  renameSync(this.path(id), this.claimedPath(id));
68
79
  return true;
69
- } // atomic; fails if already claimed/gone
80
+ }
70
81
  catch {
71
82
  return false;
72
83
  }
@@ -75,13 +86,9 @@ export class FilePendingStore {
75
86
  try {
76
87
  renameSync(this.claimedPath(id), this.path(id));
77
88
  }
78
- catch { /* already released/gone */ }
89
+ catch { }
79
90
  }
80
91
  }
81
- /** Thrown by a `withApproval`-wrapped call when the action is routed and configured
82
- * for async resume: the action is now SUSPENDED (parked in the store), not failed.
83
- * The caller can stop here — the process may even exit; the resume transport will
84
- * complete it later. */
85
92
  export class DecidioSuspendedError extends Error {
86
93
  decisionId;
87
94
  action;
@@ -96,8 +103,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
96
103
  function sign(secret, body) {
97
104
  return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
98
105
  }
99
- /** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
100
- * Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
101
106
  export function verifyResumeSignature(secret, body, header) {
102
107
  if (!header)
103
108
  return false;
@@ -107,11 +112,7 @@ export function verifyResumeSignature(secret, body, header) {
107
112
  }
108
113
  async function confirm(cfg, decisionId, capturedResponse) {
109
114
  const f = cfg.fetchImpl ?? fetch;
110
- // Sign the confirm so the DURABLE/resume path can earn application_confirmed too (bound to
111
- // {action:"confirm", scope:decisionId}); without a key the server caps it at agent_asserted.
112
115
  const requesterIdentity = signConfirm(decisionId, cfg);
113
- // Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch): the durable
114
- // path is what production agents run, so it must disclose an agent_asserted cap too.
115
116
  if (!requesterIdentity)
116
117
  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.`);
117
118
  await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
@@ -130,37 +131,31 @@ async function statusOf(cfg, decisionId) {
130
131
  throw new Error(`status ${r.status}`);
131
132
  return r.json();
132
133
  }
133
- /**
134
- * Build the resume controller. Spread it into your GuardConfig as `resume`, then
135
- * either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
136
- *
137
- * const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
138
- * const guard = { apiUrl, agentId, apiToken, resume };
139
- * const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
140
- * // production: http.createServer(resume.webhookHandler()).listen(4100)
141
- * // or CLI: resume.worker()
142
- */
143
134
  export function createDecidioResume(opts) {
144
135
  const registry = new Map();
145
- const inFlight = new Set(); // concurrent-duplicate guard (same process)
136
+ const inFlight = new Set();
146
137
  const secret = opts.webhookSecret ?? opts.apiToken ?? "";
147
138
  const ctrl = {
148
139
  ...opts,
149
- // Default the signing identity from env so a COLD resume process (a fresh worker that has
150
- // the agent's env but wasn't passed config) still signs the confirm and earns the wrapper tier.
151
140
  agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
152
141
  agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
153
142
  agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
154
- workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID, // D-B3.6: tenant-bind the cold-resume confirm proof
143
+ workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
155
144
  webhookSecret: secret,
156
145
  store: opts.store,
157
146
  register(action, fn) { registry.set(action, fn); },
147
+ async park(p) {
148
+ const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
149
+ if (!argsSig)
150
+ 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.`);
151
+ await ctrl.store.put({ ...p, createdAt: new Date().toISOString(), ...(argsSig ? { argsSig } : {}) });
152
+ },
158
153
  async resolve({ decisionId, verdict, reason }) {
159
154
  const finish = (o) => { if (o.status !== "unknown")
160
155
  opts.onResolved?.(o); return o; };
161
156
  const pending = await ctrl.store.get(decisionId);
162
157
  if (!pending)
163
- return { status: "unknown", decisionId }; // not ours / already resumed
158
+ return { status: "unknown", decisionId };
164
159
  if (verdict === "rejected") {
165
160
  await ctrl.store.delete(decisionId);
166
161
  return finish({ status: "rejected", decisionId, action: pending.action, reason });
@@ -169,21 +164,35 @@ export function createDecidioResume(opts) {
169
164
  await ctrl.store.delete(decisionId);
170
165
  return finish({ status: "blocked", decisionId, action: pending.action, reason });
171
166
  }
172
- // Fail CLOSED on any unexpected verdict — never execute on a garbage/unknown value.
173
167
  if (verdict !== "approved" && verdict !== "auto_approved")
174
168
  return { status: "unknown", decisionId };
169
+ if (pending.decisionId !== decisionId) {
170
+ 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.`);
171
+ return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
172
+ }
173
+ const strictBinding = !!ctrl.agentKey;
174
+ const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
175
+ if (integrity === "mismatch") {
176
+ 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.`);
177
+ return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload integrity check failed" });
178
+ }
179
+ if (integrity === "unverifiable") {
180
+ 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.`);
181
+ return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
182
+ }
183
+ if (integrity === "unbound" && strictBinding) {
184
+ 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.`);
185
+ return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
186
+ }
187
+ if (integrity === "unbound")
188
+ 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.`);
175
189
  const fn = registry.get(pending.action);
176
190
  if (!fn)
177
191
  return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" — call resume.register(...) at startup` });
178
- // Atomically CLAIM before executing — the cross-process single-use guarantee. If
179
- // the store has no claim primitive, fall back to an in-process guard (same-process).
180
192
  const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
181
193
  if (!claimed)
182
- return { status: "unknown", decisionId }; // another worker owns it
194
+ return { status: "unknown", decisionId };
183
195
  try {
184
- // Approved: the AGENT performs its own action FIRST. On SUCCESS clear the parked
185
- // entry (single-use) + confirm. If it throws, RELEASE the claim so the entry stays
186
- // parked + retryable — an approved action is never lost.
187
196
  const result = await fn(...pending.args);
188
197
  await ctrl.store.delete(decisionId);
189
198
  try {
@@ -213,9 +222,6 @@ export function createDecidioResume(opts) {
213
222
  req.on("data", (c) => (raw += c));
214
223
  req.on("end", async () => {
215
224
  try {
216
- // Fail CLOSED when no secret is configured: an agent that forgot to set a
217
- // webhookSecret must NOT be drivable by forged callbacks. Opt out only with
218
- // allowUnsigned (trusted network boundary).
219
225
  if (!secret) {
220
226
  if (!opts.allowUnsigned) {
221
227
  res.statusCode = 401;
@@ -282,8 +288,6 @@ export function createDecidioResume(opts) {
282
288
  st.state === "blocked" ? "blocked" : null;
283
289
  if (!verdict)
284
290
  continue;
285
- // A throwing resolve (the agent's own fn failed) must NOT kill the watcher —
286
- // the entry stays parked and is retried on the next poll.
287
291
  try {
288
292
  const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
289
293
  workerOpts?.onResolved?.(outcome);