@decidio/sdk 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -12
- package/dist/adapters/_gate.d.ts +2 -0
- package/dist/adapters/_gate.js +25 -2
- package/dist/adapters/langgraph.d.ts +13 -0
- package/dist/adapters/langgraph.js +14 -0
- package/dist/adapters/openai.d.ts +24 -0
- package/dist/adapters/openai.js +4 -0
- package/dist/adapters/temporal.d.ts +16 -0
- package/dist/adapters/temporal.js +17 -1
- package/dist/cli.js +372 -169
- package/dist/guard.d.ts +29 -0
- package/dist/guard.js +25 -0
- package/dist/index.d.ts +48 -2
- package/dist/index.js +103 -7
- package/dist/inngest.d.ts +20 -0
- package/dist/inngest.js +26 -0
- package/dist/resume.d.ts +89 -0
- package/dist/resume.js +157 -18
- package/dist/signing.d.ts +20 -0
- package/dist/signing.js +73 -0
- package/openapi.yaml +1 -1
- package/package.json +8 -2
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} (round-6 acceptance
|
|
13
|
+
* catch: the parked payload was 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 (prodsec F-03:
|
|
91
|
+
* the two were conflated, and a lost confirmation read as success). */
|
|
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 (prodsec F-07: an absolute
|
|
118
|
+
* fail-closed claim must disclose its opt-out). Only behind a trusted network
|
|
119
|
+
* boundary (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,25 @@ 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, used by ALL THREE TS confirm doors (resume, inline withApproval,
|
|
167
|
+
* adapter gate — guard equivalent-path catch: it lived only on the resume door). Transport
|
|
168
|
+
* success is not confirmation (prodsec F-03): a 2xx whose body carries no recorded tier is a
|
|
169
|
+
* FAILED confirm; a SIGNED confirm coming back below application_confirmed is disclosed, never
|
|
170
|
+
* silent. Returns the recorded tier or throws. */
|
|
171
|
+
export declare function assertConfirmRecorded(body: any, decisionId: string, signed: boolean): string;
|
|
172
|
+
/**
|
|
173
|
+
* Build the resume controller. Spread it into your GuardConfig as `resume`, then
|
|
174
|
+
* either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
|
|
175
|
+
*
|
|
176
|
+
* const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
|
|
177
|
+
* const guard = { apiUrl, agentId, apiToken, resume };
|
|
178
|
+
* const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
|
|
179
|
+
* // production: http.createServer(resume.webhookHandler()).listen(4100)
|
|
180
|
+
* // or CLI: resume.worker()
|
|
181
|
+
*/
|
|
93
182
|
export declare function createDecidioResume(opts: ResumeConfig & {
|
|
94
183
|
store: PendingStore;
|
|
95
184
|
}): ResumeController;
|
package/dist/resume.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
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).
|
|
1
20
|
import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
|
|
2
21
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
3
22
|
import { join } from "node:path";
|
|
4
|
-
import { signConfirm, canonicalize } from "./signing.js";
|
|
23
|
+
import { signConfirm, canonicalize, assertJsonSafe } from "./signing.js";
|
|
24
|
+
// ---- parked-payload integrity (round-6 acceptance catch) -------------------------------------
|
|
5
25
|
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
26
|
function signArgs(p, agentKey, webhookSecret) {
|
|
7
27
|
const bytes = argsBindingBytes(p);
|
|
@@ -10,7 +30,7 @@ function signArgs(p, agentKey, webhookSecret) {
|
|
|
10
30
|
const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
|
|
11
31
|
return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
|
|
12
32
|
}
|
|
13
|
-
catch { }
|
|
33
|
+
catch { /* fall through to hmac */ }
|
|
14
34
|
}
|
|
15
35
|
if (webhookSecret)
|
|
16
36
|
return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
|
|
@@ -48,6 +68,11 @@ export class MemoryPendingStore {
|
|
|
48
68
|
return false; this.claimed.add(id); return true; }
|
|
49
69
|
async release(id) { this.claimed.delete(id); }
|
|
50
70
|
}
|
|
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.) */
|
|
51
76
|
export class FilePendingStore {
|
|
52
77
|
dir;
|
|
53
78
|
constructor(dir) {
|
|
@@ -70,8 +95,12 @@ export class FilePendingStore {
|
|
|
70
95
|
async list() {
|
|
71
96
|
if (!existsSync(this.dir))
|
|
72
97
|
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.
|
|
73
102
|
const out = [];
|
|
74
|
-
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
|
|
103
|
+
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) { // .claimed excluded — in flight
|
|
75
104
|
try {
|
|
76
105
|
out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
|
|
77
106
|
}
|
|
@@ -85,7 +114,7 @@ export class FilePendingStore {
|
|
|
85
114
|
try {
|
|
86
115
|
renameSync(this.path(id), this.claimedPath(id));
|
|
87
116
|
return true;
|
|
88
|
-
}
|
|
117
|
+
} // atomic; fails if already claimed/gone
|
|
89
118
|
catch {
|
|
90
119
|
return false;
|
|
91
120
|
}
|
|
@@ -94,9 +123,13 @@ export class FilePendingStore {
|
|
|
94
123
|
try {
|
|
95
124
|
renameSync(this.claimedPath(id), this.path(id));
|
|
96
125
|
}
|
|
97
|
-
catch { }
|
|
126
|
+
catch { /* already released/gone */ }
|
|
98
127
|
}
|
|
99
128
|
}
|
|
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. */
|
|
100
133
|
export class DecidioSuspendedError extends Error {
|
|
101
134
|
decisionId;
|
|
102
135
|
action;
|
|
@@ -111,6 +144,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
111
144
|
function sign(secret, body) {
|
|
112
145
|
return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
|
|
113
146
|
}
|
|
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. */
|
|
114
149
|
export function verifyResumeSignature(secret, body, header) {
|
|
115
150
|
if (!header)
|
|
116
151
|
return false;
|
|
@@ -118,17 +153,39 @@ export function verifyResumeSignature(secret, body, header) {
|
|
|
118
153
|
const a = Buffer.from(expected), b = Buffer.from(header);
|
|
119
154
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
120
155
|
}
|
|
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
|
+
export function assertConfirmRecorded(body, decisionId, signed) {
|
|
162
|
+
if (!body || body.ok !== true || typeof body.confirmation !== "string") {
|
|
163
|
+
throw new Error(`confirm answered 2xx but reported no evidence tier — treating the confirmation as NOT recorded`);
|
|
164
|
+
}
|
|
165
|
+
if (signed && body.confirmation !== "application_confirmed") {
|
|
166
|
+
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.`);
|
|
167
|
+
}
|
|
168
|
+
return body.confirmation;
|
|
169
|
+
}
|
|
170
|
+
/** Resume-door confirm: POST + the shared body rule above. */
|
|
121
171
|
async function confirm(cfg, decisionId, capturedResponse) {
|
|
122
172
|
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.
|
|
123
175
|
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.
|
|
124
178
|
if (!requesterIdentity)
|
|
125
179
|
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", {
|
|
180
|
+
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
127
181
|
method: "POST",
|
|
128
182
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
129
183
|
body: JSON.stringify({ decisionId, capturedResponse: capturedResponse ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
|
|
130
|
-
})
|
|
131
|
-
|
|
184
|
+
});
|
|
185
|
+
if (!r.ok)
|
|
186
|
+
throw new Error(`confirm ${r.status}`);
|
|
187
|
+
const body = await r.json().catch(() => null);
|
|
188
|
+
return assertConfirmRecorded(body, decisionId, !!requesterIdentity);
|
|
132
189
|
}
|
|
133
190
|
async function statusOf(cfg, decisionId) {
|
|
134
191
|
const f = cfg.fetchImpl ?? fetch;
|
|
@@ -139,25 +196,58 @@ async function statusOf(cfg, decisionId) {
|
|
|
139
196
|
throw new Error(`status ${r.status}`);
|
|
140
197
|
return r.json();
|
|
141
198
|
}
|
|
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
|
+
*/
|
|
142
209
|
export function createDecidioResume(opts) {
|
|
143
210
|
const registry = new Map();
|
|
144
|
-
const inFlight = new Set();
|
|
211
|
+
const inFlight = new Set(); // concurrent-duplicate guard (same process)
|
|
145
212
|
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
|
+
if (opts.allowUnsigned)
|
|
218
|
+
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
|
+
const refusalWarned = new Set();
|
|
220
|
+
const refuseOnce = (decisionId, msg) => {
|
|
221
|
+
if (refusalWarned.has(decisionId))
|
|
222
|
+
return;
|
|
223
|
+
refusalWarned.add(decisionId);
|
|
224
|
+
console.error(msg + " (This prints once — the entry stays parked until you act.)");
|
|
225
|
+
};
|
|
146
226
|
const ctrl = {
|
|
147
227
|
...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.
|
|
148
230
|
agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
|
|
149
231
|
agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
|
|
150
232
|
agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
|
|
151
|
-
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
233
|
+
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID, // D-B3.6: tenant-bind the cold-resume confirm proof
|
|
152
234
|
webhookSecret: secret,
|
|
153
235
|
store: opts.store,
|
|
154
236
|
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.
|
|
155
240
|
const prev = registry.get(action);
|
|
156
241
|
if (prev && prev !== fn)
|
|
157
242
|
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.`);
|
|
158
243
|
registry.set(action, fn);
|
|
159
244
|
},
|
|
160
245
|
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");
|
|
161
251
|
const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
|
|
162
252
|
if (!argsSig)
|
|
163
253
|
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.`);
|
|
@@ -168,7 +258,7 @@ export function createDecidioResume(opts) {
|
|
|
168
258
|
opts.onResolved?.(o); return o; };
|
|
169
259
|
const pending = await ctrl.store.get(decisionId);
|
|
170
260
|
if (!pending)
|
|
171
|
-
return { status: "unknown", decisionId };
|
|
261
|
+
return { status: "unknown", decisionId }; // not ours / already resumed
|
|
172
262
|
if (verdict === "rejected") {
|
|
173
263
|
await ctrl.store.delete(decisionId);
|
|
174
264
|
return finish({ status: "rejected", decisionId, action: pending.action, reason });
|
|
@@ -177,30 +267,47 @@ export function createDecidioResume(opts) {
|
|
|
177
267
|
await ctrl.store.delete(decisionId);
|
|
178
268
|
return finish({ status: "blocked", decisionId, action: pending.action, reason });
|
|
179
269
|
}
|
|
270
|
+
// Fail CLOSED on any unexpected verdict — never execute on a garbage/unknown value.
|
|
180
271
|
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
181
272
|
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.
|
|
182
279
|
if (pending.decisionId !== decisionId) {
|
|
183
|
-
|
|
280
|
+
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
281
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
|
|
185
282
|
}
|
|
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).
|
|
186
288
|
const strictBinding = !!ctrl.agentKey;
|
|
187
289
|
const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
|
|
188
290
|
if (integrity === "mismatch") {
|
|
189
|
-
|
|
291
|
+
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
292
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload integrity check failed" });
|
|
191
293
|
}
|
|
192
294
|
if (integrity === "unverifiable") {
|
|
193
|
-
|
|
295
|
+
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
296
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
|
|
195
297
|
}
|
|
196
298
|
if (integrity === "unbound" && strictBinding) {
|
|
197
|
-
|
|
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
|
+
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
303
|
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
|
|
199
304
|
}
|
|
200
305
|
if (integrity === "unbound")
|
|
201
306
|
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.`);
|
|
202
307
|
const fn = registry.get(pending.action);
|
|
203
308
|
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.
|
|
204
311
|
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
205
312
|
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
206
313
|
console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
|
|
@@ -209,19 +316,34 @@ export function createDecidioResume(opts) {
|
|
|
209
316
|
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
210
317
|
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` });
|
|
211
318
|
}
|
|
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).
|
|
212
321
|
const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
|
|
213
322
|
if (!claimed)
|
|
214
|
-
return { status: "unknown", decisionId };
|
|
323
|
+
return { status: "unknown", decisionId }; // another worker owns it
|
|
215
324
|
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.
|
|
216
327
|
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.
|
|
217
332
|
let confirmed = true;
|
|
333
|
+
let tier;
|
|
218
334
|
try {
|
|
219
|
-
await confirm(ctrl, decisionId, result);
|
|
335
|
+
tier = await confirm(ctrl, decisionId, result);
|
|
220
336
|
}
|
|
221
337
|
catch (e) {
|
|
222
338
|
confirmed = false;
|
|
223
339
|
console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
|
|
224
340
|
}
|
|
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.
|
|
225
347
|
const retainedIsSafe = typeof ctrl.store.claim === "function";
|
|
226
348
|
if (confirmed || !retainedIsSafe)
|
|
227
349
|
await ctrl.store.delete(decisionId);
|
|
@@ -231,7 +353,7 @@ export function createDecidioResume(opts) {
|
|
|
231
353
|
else
|
|
232
354
|
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
355
|
}
|
|
234
|
-
return finish({ status: "executed", decisionId, action: pending.action, result });
|
|
356
|
+
return finish({ status: "executed", decisionId, action: pending.action, result, confirmed, ...(tier ? { evidenceTier: tier } : {}) });
|
|
235
357
|
}
|
|
236
358
|
catch (e) {
|
|
237
359
|
if (ctrl.store.release)
|
|
@@ -252,6 +374,9 @@ export function createDecidioResume(opts) {
|
|
|
252
374
|
req.on("data", (c) => (raw += c));
|
|
253
375
|
req.on("end", async () => {
|
|
254
376
|
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).
|
|
255
380
|
if (!secret) {
|
|
256
381
|
if (!opts.allowUnsigned) {
|
|
257
382
|
res.statusCode = 401;
|
|
@@ -318,8 +443,22 @@ export function createDecidioResume(opts) {
|
|
|
318
443
|
st.state === "blocked" ? "blocked" : null;
|
|
319
444
|
if (!verdict)
|
|
320
445
|
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.
|
|
321
448
|
try {
|
|
322
449
|
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
|
+
if (outcome.status === "executed" && outcome.confirmed)
|
|
455
|
+
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, evidence recorded as ${outcome.evidenceTier ?? "confirmed"}.`);
|
|
456
|
+
else if (outcome.status === "executed")
|
|
457
|
+
console.error(`[decidio] resumed ${p.decisionId}: approved → "${p.action}" executed, but its execution evidence did NOT reach Decidio — see the warning above.`);
|
|
458
|
+
else if (outcome.status === "rejected")
|
|
459
|
+
console.error(`[decidio] resumed ${p.decisionId}: REJECTED — the parked "${p.action}" was discarded, nothing executed.`);
|
|
460
|
+
else if (outcome.status === "blocked")
|
|
461
|
+
console.error(`[decidio] resumed ${p.decisionId}: BLOCKED by policy — the parked "${p.action}" was discarded, nothing executed.`);
|
|
323
462
|
workerOpts?.onResolved?.(outcome);
|
|
324
463
|
}
|
|
325
464
|
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,11 @@ export declare function signBoundProof(privateKeyBase64: string, fields: {
|
|
|
34
46
|
now?: number;
|
|
35
47
|
nonce?: string;
|
|
36
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
|
|
55
|
+
* number, plain array, plain object. */
|
|
56
|
+
export declare function assertJsonSafe(value: unknown, path?: string): void;
|