@decidio/sdk 0.1.9 → 0.1.11
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 +134 -29
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +69 -26
- package/dist/adapters/langgraph.js +2 -15
- package/dist/adapters/openai.js +2 -5
- package/dist/adapters/temporal.js +18 -20
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +104 -0
- package/dist/cli.js +146 -272
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +41 -0
- package/dist/evidence.js +57 -0
- package/dist/guard.d.ts +29 -8
- package/dist/guard.js +28 -35
- package/dist/index.d.ts +19 -10
- package/dist/index.js +49 -113
- package/dist/inngest.js +6 -56
- package/dist/resume.d.ts +83 -26
- package/dist/resume.js +213 -150
- package/dist/signing.d.ts +72 -6
- package/dist/signing.js +107 -41
- package/openapi.yaml +47 -1
- package/package.json +3 -9
package/README.md
CHANGED
|
@@ -114,7 +114,9 @@ step 4 printed — angle brackets are placeholders, never typed literally). Appr
|
|
|
114
114
|
the agent's floor-limited token — it cannot approve anything):
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
npx @decidio/sdk approvals
|
|
117
|
+
npx @decidio/sdk approvals --mine # see it pending (--mine narrows to THIS agent; the
|
|
118
|
+
# sandbox is one shared lab workspace, so the bare
|
|
119
|
+
# command lists every member's pending decisions)
|
|
118
120
|
npx @decidio/sdk approvals approve <decisionId>
|
|
119
121
|
```
|
|
120
122
|
|
|
@@ -126,11 +128,14 @@ becomes the record's execution evidence.
|
|
|
126
128
|
|
|
127
129
|
```bash
|
|
128
130
|
npx @decidio/sdk receipt <decisionId>
|
|
129
|
-
npx @decidio/sdk verify decidio-receipt-<decisionId>.json
|
|
131
|
+
npx @decidio/sdk verify --issuer <did:key:...> decidio-receipt-<decisionId>.json
|
|
130
132
|
```
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
+
Lead with the pin: `--issuer` binds the check to YOUR workspace's DID (the `receipt` command
|
|
135
|
+
prints it from the authenticated response, so it is already on your screen). An UNPINNED
|
|
136
|
+
`verify` proves internal consistency only — any keyholder could have issued such a file — and
|
|
137
|
+
says so; pinned verification proves *who*. The download is an authenticated fetch from your
|
|
138
|
+
workspace; the *verification* is fully offline — the verifier ships inside this package (no second download, no registry probe, same
|
|
134
139
|
implementation as the standalone `@decidio/verify`). The receipt is a W3C Verifiable
|
|
135
140
|
Credential: signature and content binding verify with no Decidio account and no network — the
|
|
136
141
|
evidence is yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
|
|
@@ -153,11 +158,20 @@ evidence that arrives *after* the seal (your function's captured result) lives o
|
|
|
153
158
|
**record**, not inside the credential bytes: the `receipt` command prints the record's current
|
|
154
159
|
tier next to the file, and with the signing key `init` wrote you'll see `application_confirmed`
|
|
155
160
|
— the wrapper's report carried a signed identity proof bound to your agent, this decision, and
|
|
156
|
-
a freshness window.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
a freshness window.
|
|
162
|
+
|
|
163
|
+
Since 0.1.11 the report also carries an **execution report**: a separate, domain-separated,
|
|
164
|
+
versioned envelope signed over a SHA-256 digest of the canonicalized response, bound to the
|
|
165
|
+
decision, your agent and your workspace, with a single-use nonce and a short expiry. The server
|
|
166
|
+
recomputes that digest from the response on the same request and refuses the attestation if they
|
|
167
|
+
disagree — so "this agent reported this exact result for this decision" is checked, not asserted.
|
|
168
|
+
It is deliberately separate from the evidence TIER: the tier says how strongly the outcome was
|
|
169
|
+
observed, the report says what was reported and by whom, and no signature can turn a weak
|
|
170
|
+
observation into a strong one. An unverifiable report is refused as an attestation (and the
|
|
171
|
+
refusal is itself audited) while the confirmation is still recorded — an optional proof never
|
|
172
|
+
destroys the record of an execution that really happened; the confirm response tells you which
|
|
173
|
+
happened. (No signing key configured? No execution report is sent, the server caps the evidence
|
|
174
|
+
at the weaker `agent_asserted`, and the SDK warns at confirm time.) Immutable seal + accruing evidence is the design, not an omission — corrections and
|
|
161
175
|
later facts supersede, they never rewrite.
|
|
162
176
|
|
|
163
177
|
One sharing note: a receipt is **not a secret, but it is not metadata-free** — it names the
|
|
@@ -201,29 +215,47 @@ exit. Two ways the parked action resumes when a human decides:
|
|
|
201
215
|
**The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
|
|
202
216
|
the current process never started. Here is a complete `agent.mjs` you can run:
|
|
203
217
|
|
|
218
|
+
Two files, on purpose — **the worker and the thing that creates work are different programs**.
|
|
219
|
+
A single file that both starts the worker and calls the action submits a NEW request every time
|
|
220
|
+
you restart it, which is the opposite of what a recovery test should show.
|
|
221
|
+
|
|
222
|
+
`agent.mjs` — the long-lived worker. Restart it as often as you like; it creates nothing:
|
|
223
|
+
|
|
204
224
|
```js
|
|
205
225
|
import { guard } from "@decidio/sdk";
|
|
206
226
|
|
|
207
|
-
const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id });
|
|
227
|
+
const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id, success: true });
|
|
208
228
|
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
229
|
+
// Declaring the action name registers the handler at LOAD time, not on first call — that is
|
|
230
|
+
// what lets a restarted process resume actions approved while it was down.
|
|
231
|
+
guard.protect(
|
|
212
232
|
payInvoiceRaw,
|
|
213
233
|
(invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
|
|
214
234
|
{ action: "payInvoice" },
|
|
215
235
|
);
|
|
216
236
|
|
|
217
|
-
guard.worker(); // polls Decidio
|
|
237
|
+
guard.worker(); // polls Decidio and resumes approved actions; creates no work of its own
|
|
238
|
+
console.log("worker running — approve a decision and watch it resume here");
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`submit.mjs` — run this ONCE when you want a new request:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
import { guard } from "@decidio/sdk";
|
|
245
|
+
|
|
246
|
+
const payInvoice = guard.protect(
|
|
247
|
+
async (invoice) => ({ paid: true, id: invoice.id, success: true }),
|
|
248
|
+
(invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
|
|
249
|
+
{ action: "payInvoice" },
|
|
250
|
+
);
|
|
218
251
|
|
|
219
|
-
// New work suspends immediately; approvals land later, through the same worker.
|
|
220
252
|
try { await payInvoice({ id: "INV-1", amount: 86_000 }); }
|
|
221
253
|
catch (e) { console.log(e.name); } // DecidioSuspendedError — expected, not a failure
|
|
222
254
|
```
|
|
223
255
|
|
|
224
|
-
Run
|
|
225
|
-
worker executes it.
|
|
226
|
-
|
|
256
|
+
Run `node --env-file=.env agent.mjs`, then `node --env-file=.env submit.mjs` once, approve from
|
|
257
|
+
another terminal, and the running worker executes it. Now kill the worker, restart it, and
|
|
258
|
+
approve: it still executes — and restarting created no second request. **If you can't declare the name at wrap time** (a dynamic
|
|
227
259
|
`describe`), register the raw function explicitly at startup instead —
|
|
228
260
|
`guard.register("payInvoice", payInvoiceRaw)` — before calling `guard.worker()`. Without one of
|
|
229
261
|
those two, a cold worker has an empty registry and reports `no_handler` (the parked action is
|
|
@@ -243,18 +275,66 @@ also allow-list resume hosts (default-deny). That's operator territory: if you r
|
|
|
243
275
|
Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
|
|
244
276
|
use the worker — a webhook secret your server never learned would (correctly) fail closed.
|
|
245
277
|
|
|
278
|
+
**Earning the strongest evidence tier.** Decidio records `application_confirmed` — its
|
|
279
|
+
strongest wrapper tier — only when your function's captured response shows a committed record:
|
|
280
|
+
a record id (`id` / `recordId` / `Id`) and no explicit denial of success. Precisely, as
|
|
281
|
+
implemented: an id with no `success`/`ok` field is taken as a commit; `success: false` (or
|
|
282
|
+
`ok: false`) records `execution_unconfirmed` even with an id; and a response carrying no id at
|
|
283
|
+
all records `execution_unconfirmed`, because Decidio then has no evidence the downstream write
|
|
284
|
+
committed. Note what this does and does not mean: Decidio is trusting your wrapper's report of
|
|
285
|
+
an identifier — it does not independently read that record back (that is the stronger
|
|
286
|
+
`independently_reconciled` tier). If your API returns an id before the write is durable (a
|
|
287
|
+
202-Accepted or queued shape), map an explicit success flag with `evidence` so the tier reflects
|
|
288
|
+
the commit rather than the acknowledgement. Keep your API's natural shape and map it instead:
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
guard.protect(chargeCard, describe, {
|
|
292
|
+
action: "chargeCard",
|
|
293
|
+
evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" }),
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The SDK tells you which of the two is missing rather than leaving you to guess, and it reports
|
|
298
|
+
`reportRecorded` (Decidio accepted the report) separately from `executionConfirmed` (the
|
|
299
|
+
recorded tier attests a commit) — two different questions that used to share one name.
|
|
300
|
+
|
|
246
301
|
**The supported argument data model is plain JSON**: `null`, booleans, strings, finite
|
|
247
302
|
numbers, arrays, and plain objects. Anything that cannot survive JSON with its meaning intact
|
|
248
303
|
— `NaN`/`Infinity`, `undefined`, `BigInt`, `Map`/`Set`/`Date`, class instances, functions,
|
|
249
|
-
circular structures
|
|
250
|
-
(typed error naming the offending path),
|
|
251
|
-
would mean a human approves one thing and
|
|
304
|
+
circular structures, symbol keys, getters, non-enumerable or sparse values — is **refused
|
|
305
|
+
before the decision is routed or the payload signed** (typed error naming the offending path),
|
|
306
|
+
because a value that silently drifts in serialization would mean a human approves one thing and
|
|
307
|
+
your function executes another.
|
|
308
|
+
|
|
309
|
+
Your arguments are canonicalized ONCE, and that canonical snapshot is what `describe` sees, what
|
|
310
|
+
the signature covers, what is parked, and what your function ultimately receives — so the
|
|
311
|
+
approved value and the executed value are the same. Consequence worth stating plainly: JSON
|
|
312
|
+
canonicalization preserves **value** semantics, not JavaScript object identity. Two properties
|
|
313
|
+
pointing at the same object arrive as two equal objects, and mutating an argument inside your
|
|
314
|
+
function does not affect the caller's copy.
|
|
315
|
+
|
|
316
|
+
**What the SDK can and cannot promise about duplicates.** It prevents *concurrent* execution:
|
|
317
|
+
an entry is claimed atomically before it runs, so two workers cannot both execute it and a
|
|
318
|
+
duplicate signal finds nothing to replay. It cannot make an external side effect exactly-once —
|
|
319
|
+
no client can. If your action succeeds and the process dies before the result is recorded,
|
|
320
|
+
whether it ran is not knowable from this machine. So the contract is **at-least-once with
|
|
321
|
+
honest recovery**, and your downstream action should be idempotent (the decision id makes a
|
|
322
|
+
natural idempotency key).
|
|
323
|
+
|
|
324
|
+
Recovery is explicit rather than magical:
|
|
252
325
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
326
|
+
```js
|
|
327
|
+
for (const item of await guard.resume.recover()) {
|
|
328
|
+
console.log(item.state, item.decisionId, item.guidance);
|
|
329
|
+
}
|
|
330
|
+
// "executed_unreported" → guard.resume.reconfirm(id) re-sends the saved report (never re-runs)
|
|
331
|
+
// "indeterminate" → a process died mid-flight; reconcile downstream, do NOT blind-retry
|
|
332
|
+
// "parked" → still waiting on a human; nothing has executed
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The report a worker sends is journalled *before* the first attempt, so a lost confirmation is
|
|
336
|
+
recoverable; and because the journal holds no handler binding, replaying it can only re-send
|
|
337
|
+
evidence — never re-run your function.
|
|
258
338
|
|
|
259
339
|
**Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
|
|
260
340
|
disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
|
|
@@ -278,12 +358,21 @@ If your agent runs on Inngest/Temporal/LangGraph, the engine *is* the durable st
|
|
|
278
358
|
`@decidio/sdk/inngest` maps the gate onto `step.waitForEvent`; `@decidio/sdk/langgraph` is a
|
|
279
359
|
drop-in via `interrupt()`; `@decidio/sdk/temporal` uses `condition` + signal;
|
|
280
360
|
`@decidio/sdk/openai` gates RunState approvals. All optional peer dependencies — the core SDK
|
|
281
|
-
imports none of them
|
|
361
|
+
imports none of them — install the engine you use alongside the SDK (`npm i inngest`,
|
|
362
|
+
`@langchain/langgraph`, `@temporalio/workflow`, or `@openai/agents`); nothing is downloaded
|
|
363
|
+
for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
|
|
282
364
|
@decidio/sdk quickstart` is the fastest runnable end-to-end.
|
|
283
365
|
|
|
284
|
-
##
|
|
366
|
+
## Engine adapters (BETA) — and Python too
|
|
285
367
|
|
|
286
|
-
-
|
|
368
|
+
- **Engine adapters are BETA.** They share the core's gate semantics — the verdict allow-list,
|
|
369
|
+
the canonical argument model, the transport rule and evidence handling are ONE implementation,
|
|
370
|
+
and a conformance matrix runs the same malformed-verdict, unsafe-argument and plaintext-transport
|
|
371
|
+
battery against every door. What is *not* yet verified is the engine integration itself: those
|
|
372
|
+
paths are exercised against structural mocks, not live LangGraph/Temporal/Inngest/OpenAI
|
|
373
|
+
runtimes. Use the core path for production until that changes. Each adapter is bound through its
|
|
374
|
+
own `@decidio/sdk/<engine>` subpath (they need the engine handle at call time); no engine at all?
|
|
375
|
+
The worker/webhook resume is the universal path and is not beta.
|
|
287
376
|
- **Python twin:** `pip install decidio` exposes the identical `guard.protect` (plus the `@guard.approve` decorator, adapters, and the offline verifier); a conformance suite asserts both languages emit an identical request + receipt.
|
|
288
377
|
|
|
289
378
|
## CLI
|
|
@@ -317,6 +406,22 @@ manager for production.
|
|
|
317
406
|
- `DecidioRejectedError` — a human rejected the routed action.
|
|
318
407
|
- `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
|
|
319
408
|
- `DecidioTimeoutError` — (blocking mode only) no human decided within `pollTimeoutMs` (default 10 min); the decision stays open in Decidio, nothing executed locally.
|
|
409
|
+
- `DecidioRateLimitError` — the workspace's governed-action limit was hit; carries the server's `limit` / `remaining` / `resetAt`.
|
|
410
|
+
- `DecidioHttpError` — the gate refused the call. Carries `status`, `endpoint`, the parsed `body`,
|
|
411
|
+
and **`retryable`** — the field to branch on. A 401/403 is terminal (the agent token expired, or
|
|
412
|
+
someone re-ran `init` for that agent and revoked it): retrying cannot help, so the message names
|
|
413
|
+
the fix. Anything else is transient, and retrying is safe because a retry creates a *new* request
|
|
414
|
+
and can never double-execute.
|
|
415
|
+
- `DecidioNetworkError` — the gate was unreachable. Nothing executed locally.
|
|
416
|
+
|
|
417
|
+
```js
|
|
418
|
+
try { await payInvoice(inv); }
|
|
419
|
+
catch (e) {
|
|
420
|
+
if (e instanceof DecidioHttpError && !e.retryable) { await alertOncall(e.message); throw e; }
|
|
421
|
+
if (e instanceof DecidioHttpError) { await backoff(); return retry(); }
|
|
422
|
+
throw e;
|
|
423
|
+
}
|
|
424
|
+
```
|
|
320
425
|
|
|
321
426
|
Every error raised after a decision exists carries the `decisionId`, so a suspended or
|
|
322
427
|
timed-out action is always findable in
|
package/dist/adapters/_gate.d.ts
CHANGED
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
import { type GuardConfig, type ApprovalContext } from "../index.js";
|
|
2
|
+
/** The ONLY authorization verdicts this SDK understands. A gate answer outside this set means
|
|
3
|
+
* the SDK does not understand the decision — and an answer you do not understand means NO. */
|
|
4
|
+
export declare const AUTHORIZE_VERDICTS: readonly ["proceed", "route", "block"];
|
|
5
|
+
export type AuthorizeVerdict = (typeof AUTHORIZE_VERDICTS)[number];
|
|
2
6
|
export interface GateAuth {
|
|
3
|
-
decision:
|
|
7
|
+
decision: AuthorizeVerdict;
|
|
4
8
|
decisionId: string;
|
|
5
9
|
receiptId?: string;
|
|
6
10
|
reason?: string;
|
|
7
11
|
}
|
|
12
|
+
/** Fail-closed verdict allow-list. Called INSIDE gateAuthorize, so every adapter that authorizes
|
|
13
|
+
* through the shared gate is protected by construction rather than by remembering — an adapter
|
|
14
|
+
* that handled `block` and `route` and let everything else fall through to execute is exactly
|
|
15
|
+
* how an unknown verdict reached a protected action. Version skew (the day the gate learns a
|
|
16
|
+
* fourth verdict) is the realistic trigger, and it must never mean "run it". */
|
|
17
|
+
export declare function assertAuthorizeVerdict(auth: unknown, fallbackDecisionId?: string): GateAuth;
|
|
8
18
|
export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext): Promise<GateAuth>;
|
|
9
19
|
export declare function gateConfirm(config: GuardConfig, decisionId: string, result: unknown): Promise<void>;
|
|
10
20
|
/** Normalize a resume payload ({verdict} | "approved" | ...) to a verdict string. */
|
|
11
21
|
export declare function verdictFrom(payload: any): string;
|
|
12
22
|
/** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
|
|
13
23
|
export declare function assertVerdict(verdict: string, decisionId: string, reason?: string): void;
|
|
24
|
+
/** Engine versions these adapters are written and tested against. Peer dependencies are
|
|
25
|
+
* deliberately NOT declared: declaring them made npm resolve four engine ecosystems into ~211MB
|
|
26
|
+
* of cache metadata for every consumer, including the majority who use none of them. The
|
|
27
|
+
* compatibility contract lives here instead, and it is CHECKED at bind time — a mismatch names
|
|
28
|
+
* the version it found, the range it wants, the beta status and the exact remediation, rather
|
|
29
|
+
* than failing somewhere deep in the engine later. */
|
|
30
|
+
export declare const ENGINE_RANGES: Record<string, {
|
|
31
|
+
pkg: string;
|
|
32
|
+
min: string;
|
|
33
|
+
lessThan: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** Warn (never throw — a working integration must not be broken by a version table) when the
|
|
36
|
+
* installed engine is outside the tested range. Called by each adapter at bind time. */
|
|
37
|
+
export declare function checkEngineVersion(adapter: keyof typeof ENGINE_RANGES | string): void;
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, canonicalize, readConfirmResponse, buildConfirmBody } from "../index.js";
|
|
3
|
+
export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
|
|
4
|
+
export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
5
|
+
const a = auth;
|
|
6
|
+
const decisionId = typeof a?.decisionId === "string" && a.decisionId ? a.decisionId : fallbackDecisionId;
|
|
7
|
+
if (!a || !AUTHORIZE_VERDICTS.includes(a.decision)) {
|
|
8
|
+
throw new DecidioBlockedError(`unrecognised authorization verdict ${JSON.stringify(a?.decision ?? null)} — refusing to execute. The gate answers ${AUTHORIZE_VERDICTS.join(" | ")}; anything else means this SDK version does not understand the decision (upgrade @decidio/sdk), and an answer you do not understand is never permission.`, decisionId);
|
|
9
|
+
}
|
|
10
|
+
return a;
|
|
11
|
+
}
|
|
6
12
|
export async function gateAuthorize(config, ctx) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// a human would approve an amount that meant something else entirely).
|
|
10
|
-
assertJsonSafe(ctx, "describe(...)");
|
|
13
|
+
canonicalize(ctx, "describe(...)");
|
|
14
|
+
assertSafeTransport(config.apiUrl, !!config.apiToken);
|
|
11
15
|
const f = config.fetchImpl ?? fetch;
|
|
12
|
-
// Fall back to the resume controller's URL (matches the core wrapper) so an adapter
|
|
13
|
-
// decision carries a callback URL even when only resume.resumeUrl was set.
|
|
14
16
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
15
|
-
// Sign the bound identity proof like the core wrapper, so an adapter-path agent can PROVE
|
|
16
|
-
// its identity and auto-approve (not just route). No key → unsigned → server routes.
|
|
17
17
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
18
18
|
? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
|
|
19
19
|
: undefined;
|
|
@@ -26,42 +26,53 @@ export async function gateAuthorize(config, ctx) {
|
|
|
26
26
|
...(requesterIdentity ? { requesterIdentity } : {}), ...(resumeUrl ? { resumeUrl } : {}),
|
|
27
27
|
}),
|
|
28
28
|
});
|
|
29
|
-
if (!res.ok)
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const raw = await res.text().catch(() => "");
|
|
31
|
+
let parsed = raw;
|
|
32
|
+
try {
|
|
33
|
+
parsed = raw ? JSON.parse(raw) : undefined;
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
throw new DecidioHttpError(res.status, "/agent/authorize", parsed);
|
|
37
|
+
}
|
|
38
|
+
return assertAuthorizeVerdict(await res.json(), "unknown");
|
|
32
39
|
}
|
|
33
40
|
export async function gateConfirm(config, decisionId, result) {
|
|
34
41
|
const f = config.fetchImpl ?? fetch;
|
|
35
42
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
assertSafeTransport(config.apiUrl, !!config.apiToken);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
38
50
|
const requesterIdentity = signConfirm(decisionId, config);
|
|
39
|
-
// Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch).
|
|
40
51
|
if (!requesterIdentity)
|
|
41
52
|
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.`);
|
|
53
|
+
const reported = canonicalize(result ?? null, "result");
|
|
42
54
|
const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
43
55
|
method: "POST",
|
|
44
56
|
headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
|
|
45
|
-
body: JSON.stringify(
|
|
57
|
+
body: JSON.stringify(buildConfirmBody(config, decisionId, reported, requesterIdentity)),
|
|
46
58
|
});
|
|
47
|
-
// fetch only rejects on network error — surface a non-2xx (auth/500) too. Best-effort:
|
|
48
|
-
// log, don't throw (the action already happened).
|
|
49
59
|
if (!res.ok) {
|
|
50
60
|
console.warn(`[decidio] confirm returned ${res.status} (action succeeded)`);
|
|
51
61
|
return;
|
|
52
62
|
}
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
const ev = readConfirmResponse(await res.json().catch(() => null), { signed: !!requesterIdentity, capturedResponse: reported });
|
|
64
|
+
if (!ev.reportRecorded)
|
|
65
|
+
console.warn(`[decidio] ${decisionId}: ${ev.diagnostic}`);
|
|
66
|
+
else if (!ev.executionConfirmed)
|
|
67
|
+
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
|
|
55
68
|
}
|
|
56
69
|
catch (e) {
|
|
57
70
|
console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
|
|
58
71
|
}
|
|
59
72
|
}
|
|
60
|
-
/** Normalize a resume payload ({verdict} | "approved" | ...) to a verdict string. */
|
|
61
73
|
export function verdictFrom(payload) {
|
|
62
74
|
return (payload && typeof payload === "object" ? payload.verdict : payload) ?? "";
|
|
63
75
|
}
|
|
64
|
-
/** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
|
|
65
76
|
export function assertVerdict(verdict, decisionId, reason) {
|
|
66
77
|
if (verdict === "approved" || verdict === "auto_approved")
|
|
67
78
|
return;
|
|
@@ -69,3 +80,35 @@ export function assertVerdict(verdict, decisionId, reason) {
|
|
|
69
80
|
throw new DecidioRejectedError(reason ?? "rejected by approver", decisionId);
|
|
70
81
|
throw new DecidioBlockedError(reason ?? "not approved", decisionId);
|
|
71
82
|
}
|
|
83
|
+
export const ENGINE_RANGES = {
|
|
84
|
+
langgraph: { pkg: "@langchain/langgraph", min: "0.2.0", lessThan: "2.0.0" },
|
|
85
|
+
temporal: { pkg: "@temporalio/workflow", min: "1.0.0", lessThan: "2.0.0" },
|
|
86
|
+
openai: { pkg: "@openai/agents", min: "0.0.1", lessThan: "2.0.0" },
|
|
87
|
+
inngest: { pkg: "inngest", min: "3.0.0", lessThan: "4.0.0" },
|
|
88
|
+
};
|
|
89
|
+
const cmp = (a, b) => {
|
|
90
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0), pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
91
|
+
for (let i = 0; i < 3; i++) {
|
|
92
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
93
|
+
return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
};
|
|
97
|
+
export function checkEngineVersion(adapter) {
|
|
98
|
+
const spec = ENGINE_RANGES[adapter];
|
|
99
|
+
if (!spec)
|
|
100
|
+
return;
|
|
101
|
+
let found;
|
|
102
|
+
try {
|
|
103
|
+
const req = createRequire(import.meta.url);
|
|
104
|
+
found = req(`${spec.pkg}/package.json`)?.version;
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
if (!found)
|
|
108
|
+
return;
|
|
109
|
+
if (cmp(found, spec.min) < 0 || cmp(found, spec.lessThan) >= 0) {
|
|
110
|
+
console.warn(`[decidio] ${spec.pkg} ${found} is outside the range this BETA adapter is tested against (>=${spec.min} <${spec.lessThan}). ` +
|
|
111
|
+
`The adapter may still work, but nothing verifies it at this version. Remediation: install a supported version ` +
|
|
112
|
+
`(npm i "${spec.pkg}@>=${spec.min} <${spec.lessThan}"), or use the core guard.protect path, which is not beta and needs no engine.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
import { interrupt, Command } from "@langchain/langgraph";
|
|
2
2
|
import { DecidioBlockedError } from "../index.js";
|
|
3
|
-
import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict } from "./_gate.js";
|
|
4
|
-
/**
|
|
5
|
-
* @decidio/sdk/langgraph — durable suspend via LangGraph's native `interrupt()`.
|
|
6
|
-
* `interrupt` finds the running graph itself (no handle needed), so the gate drops into
|
|
7
|
-
* a node: authorize → on route, interrupt() durably pauses (the checkpointer persists;
|
|
8
|
-
* the process can die) → on resume (Command(resume={verdict})) run the agent's own
|
|
9
|
-
* action + confirm. Decidio is the approval system; the host bridges its webhook to the
|
|
10
|
-
* graph resume (decidioResumeCommand). Requires @langchain/langgraph (optional peer dep).
|
|
11
|
-
*
|
|
12
|
-
* (This wiring doc ships in the .d.ts — the README points here.)
|
|
13
|
-
*/
|
|
14
|
-
/** Gate `run()` behind Decidio inside a LangGraph node. */
|
|
3
|
+
import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion } from "./_gate.js";
|
|
15
4
|
export async function decidioGate(config, ctx, run) {
|
|
16
5
|
const auth = await gateAuthorize(config, ctx);
|
|
17
6
|
if (auth.decision === "block")
|
|
18
7
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
19
8
|
if (auth.decision === "route") {
|
|
20
|
-
// Durable pause. On resume, interrupt() returns the Command(resume=...) payload.
|
|
21
9
|
const resumed = interrupt({ decidio: { decisionId: auth.decisionId, action: ctx.action, amount: ctx.amount, scope: ctx.scope } });
|
|
22
10
|
assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
|
|
23
11
|
}
|
|
@@ -25,8 +13,7 @@ export async function decidioGate(config, ctx, run) {
|
|
|
25
13
|
await gateConfirm(config, auth.decisionId, result);
|
|
26
14
|
return result;
|
|
27
15
|
}
|
|
28
|
-
/** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
|
|
29
|
-
* maps decisionId → thread_id and passes this to graph.invoke/stream. */
|
|
30
16
|
export function decidioResumeCommand(body) {
|
|
31
17
|
return new Command({ resume: { verdict: body.verdict, reason: body.reason } });
|
|
32
18
|
}
|
|
19
|
+
checkEngineVersion("langgraph");
|
package/dist/adapters/openai.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import { gateAuthorize } from "./_gate.js";
|
|
2
|
-
/** Ask Decidio for each pending tool-approval on a RunState.
|
|
3
|
-
* proceed → state.approve(item); block → state.reject(item); route → pending (a human
|
|
4
|
-
* decides via Decidio; the host parks state.toString() + resumes via applyResume). */
|
|
1
|
+
import { gateAuthorize, checkEngineVersion } from "./_gate.js";
|
|
5
2
|
export async function gateInterruptions(config, state, describe) {
|
|
6
3
|
const resolved = [];
|
|
7
4
|
const pending = [];
|
|
@@ -20,10 +17,10 @@ export async function gateInterruptions(config, state, describe) {
|
|
|
20
17
|
}
|
|
21
18
|
return { resolved, pending };
|
|
22
19
|
}
|
|
23
|
-
/** Apply a Decidio verdict to a resumed RunState (after RunState.fromString). */
|
|
24
20
|
export function applyResume(state, item, verdict) {
|
|
25
21
|
if (verdict === "approved" || verdict === "auto_approved")
|
|
26
22
|
state.approve(item);
|
|
27
23
|
else
|
|
28
24
|
state.reject(item);
|
|
29
25
|
}
|
|
26
|
+
checkEngineVersion("openai");
|
|
@@ -1,38 +1,36 @@
|
|
|
1
1
|
import { condition, defineSignal, setHandler } from "@temporalio/workflow";
|
|
2
2
|
import { DecidioBlockedError } from "../index.js";
|
|
3
|
-
import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict } from "./_gate.js";
|
|
4
|
-
/**
|
|
5
|
-
* @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
|
|
6
|
-
* Decidio resume signal. Strongest durability (the workflow survives worker restarts for
|
|
7
|
-
* days); the agent's action runs on resume. Requires @temporalio/workflow (optional peer).
|
|
8
|
-
*
|
|
9
|
-
* Inside your workflow:
|
|
10
|
-
* const verdicts = installDecidioSignal(); // wires the resume signal
|
|
11
|
-
* const out = await decidioGate(config, ctx, () => createOpp(o), verdicts, {
|
|
12
|
-
* authorize: () => executeActivity(authorizeActivity, ...), // HTTP belongs in activities
|
|
13
|
-
* confirm: (id, r) => executeActivity(confirmActivity, ...),
|
|
14
|
-
* });
|
|
15
|
-
* Decidio's webhook bridges to: handle.signal(decidioResumeSignal, decisionId, verdict, reason)
|
|
16
|
-
*
|
|
17
|
-
* (This wiring doc ships in the .d.ts — the README points here.)
|
|
18
|
-
*/
|
|
3
|
+
import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion, assertAuthorizeVerdict } from "./_gate.js";
|
|
19
4
|
export const decidioResumeSignal = defineSignal("decidioResume");
|
|
20
|
-
/** Call inside the workflow to wire the resume signal; returns the verdict store. */
|
|
21
5
|
export function installDecidioSignal() {
|
|
22
6
|
const verdicts = new Map();
|
|
23
7
|
setHandler(decidioResumeSignal, (decisionId, verdict, reason) => { verdicts.set(decisionId, { verdict, reason }); });
|
|
24
8
|
return verdicts;
|
|
25
9
|
}
|
|
10
|
+
let confirmWarned = false;
|
|
26
11
|
export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
27
|
-
const auth = await (opts.authorize ?? (() => gateAuthorize(config, ctx)))();
|
|
12
|
+
const auth = assertAuthorizeVerdict(await (opts.authorize ?? (() => gateAuthorize(config, ctx)))(), "unknown");
|
|
28
13
|
if (auth.decision === "block")
|
|
29
14
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
30
15
|
if (auth.decision === "route") {
|
|
31
|
-
await condition(() => verdicts.has(auth.decisionId));
|
|
16
|
+
await condition(() => verdicts.has(auth.decisionId));
|
|
32
17
|
const v = verdicts.get(auth.decisionId);
|
|
33
18
|
assertVerdict(verdictFrom(v), auth.decisionId, v.reason);
|
|
34
19
|
}
|
|
20
|
+
else if (auth.decision !== "proceed") {
|
|
21
|
+
throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)} — refusing to execute`, auth.decisionId);
|
|
22
|
+
}
|
|
35
23
|
const result = await run();
|
|
36
|
-
|
|
24
|
+
if (opts.confirm) {
|
|
25
|
+
await opts.confirm(auth.decisionId, result);
|
|
26
|
+
if (!confirmWarned) {
|
|
27
|
+
confirmWarned = true;
|
|
28
|
+
console.warn(`[decidio] temporal: a custom \`confirm\` reports through your own activity, so this SDK cannot read back the evidence tier Decidio recorded. Check the record (or return the gate's response body from your activity and validate it with readConfirmResponse) if you need to know whether the execution was CONFIRMED, not merely reported.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
await gateConfirm(config, auth.decisionId, result);
|
|
33
|
+
}
|
|
37
34
|
return result;
|
|
38
35
|
}
|
|
36
|
+
checkEngineVersion("temporal");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The value model this SDK transports: exactly what JSON can carry, losslessly. */
|
|
2
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
3
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | {
|
|
4
|
+
[key: string]: JsonValue;
|
|
5
|
+
};
|
|
6
|
+
/** Typed refusal for a value outside the canonical model. `decisionId` is set only when the
|
|
7
|
+
* refusal happens after a decision already exists (a direct-park caller). */
|
|
8
|
+
export declare class DecidioUnsafeArgumentsError extends Error {
|
|
9
|
+
decisionId?: string | undefined;
|
|
10
|
+
constructor(message: string, decisionId?: string | undefined);
|
|
11
|
+
}
|
|
12
|
+
/** Deep-clone `value` into the canonical model, REFUSING anything that cannot be carried
|
|
13
|
+
* losslessly. Returns a fresh structure; the input is never referenced by the result. */
|
|
14
|
+
export declare function canonicalize<T = JsonValue>(value: unknown, path?: string): T;
|
|
15
|
+
export declare function canonicalizeArgs(args: readonly unknown[]): JsonValue[];
|
|
16
|
+
/** RFC 8785-shaped canonical JSON of an already-canonical value — the exact bytes hashed for an
|
|
17
|
+
* execution report, so both languages produce the same digest. */
|
|
18
|
+
export declare function canonicalJson(value: JsonValue): string;
|