@decidio/sdk 0.1.10 → 0.1.12
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 +126 -25
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +76 -9
- package/dist/adapters/langgraph.js +2 -1
- package/dist/adapters/openai.js +2 -1
- package/dist/adapters/temporal.js +17 -3
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +128 -0
- package/dist/cli.js +182 -30
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/evidence.js +65 -0
- package/dist/guard.d.ts +22 -3
- package/dist/guard.js +18 -11
- package/dist/index.d.ts +13 -2
- package/dist/index.js +58 -29
- package/dist/inngest.js +6 -30
- package/dist/resume.d.ts +78 -17
- package/dist/resume.js +226 -55
- package/dist/signing.d.ts +64 -0
- package/dist/signing.js +102 -3
- package/openapi.yaml +47 -1
- package/package.json +2 -2
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
|
|
|
@@ -156,11 +158,20 @@ evidence that arrives *after* the seal (your function's captured result) lives o
|
|
|
156
158
|
**record**, not inside the credential bytes: the `receipt` command prints the record's current
|
|
157
159
|
tier next to the file, and with the signing key `init` wrote you'll see `application_confirmed`
|
|
158
160
|
— the wrapper's report carried a signed identity proof bound to your agent, this decision, and
|
|
159
|
-
a freshness window.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
164
175
|
later facts supersede, they never rewrite.
|
|
165
176
|
|
|
166
177
|
One sharing note: a receipt is **not a secret, but it is not metadata-free** — it names the
|
|
@@ -204,29 +215,47 @@ exit. Two ways the parked action resumes when a human decides:
|
|
|
204
215
|
**The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
|
|
205
216
|
the current process never started. Here is a complete `agent.mjs` you can run:
|
|
206
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
|
+
|
|
207
224
|
```js
|
|
208
225
|
import { guard } from "@decidio/sdk";
|
|
209
226
|
|
|
210
|
-
const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id });
|
|
227
|
+
const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id, success: true });
|
|
211
228
|
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
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(
|
|
215
232
|
payInvoiceRaw,
|
|
216
233
|
(invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
|
|
217
234
|
{ action: "payInvoice" },
|
|
218
235
|
);
|
|
219
236
|
|
|
220
|
-
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
|
+
);
|
|
221
251
|
|
|
222
|
-
// New work suspends immediately; approvals land later, through the same worker.
|
|
223
252
|
try { await payInvoice({ id: "INV-1", amount: 86_000 }); }
|
|
224
253
|
catch (e) { console.log(e.name); } // DecidioSuspendedError — expected, not a failure
|
|
225
254
|
```
|
|
226
255
|
|
|
227
|
-
Run
|
|
228
|
-
worker executes it.
|
|
229
|
-
|
|
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
|
|
230
259
|
`describe`), register the raw function explicitly at startup instead —
|
|
231
260
|
`guard.register("payInvoice", payInvoiceRaw)` — before calling `guard.worker()`. Without one of
|
|
232
261
|
those two, a cold worker has an empty registry and reports `no_handler` (the parked action is
|
|
@@ -246,18 +275,66 @@ also allow-list resume hosts (default-deny). That's operator territory: if you r
|
|
|
246
275
|
Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
|
|
247
276
|
use the worker — a webhook secret your server never learned would (correctly) fail closed.
|
|
248
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
|
+
|
|
249
301
|
**The supported argument data model is plain JSON**: `null`, booleans, strings, finite
|
|
250
302
|
numbers, arrays, and plain objects. Anything that cannot survive JSON with its meaning intact
|
|
251
303
|
— `NaN`/`Infinity`, `undefined`, `BigInt`, `Map`/`Set`/`Date`, class instances, functions,
|
|
252
|
-
circular structures
|
|
253
|
-
(typed error naming the offending path),
|
|
254
|
-
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:
|
|
255
325
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
|
261
338
|
|
|
262
339
|
**Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
|
|
263
340
|
disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
|
|
@@ -286,9 +363,16 @@ imports none of them — install the engine you use alongside the SDK (`npm i in
|
|
|
286
363
|
for engines you don't. Each adapter's shipped `.d.ts` header documents its wiring; `npx
|
|
287
364
|
@decidio/sdk quickstart` is the fastest runnable end-to-end.
|
|
288
365
|
|
|
289
|
-
##
|
|
366
|
+
## Engine adapters (BETA) — and Python too
|
|
290
367
|
|
|
291
|
-
-
|
|
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.
|
|
292
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.
|
|
293
377
|
|
|
294
378
|
## CLI
|
|
@@ -322,6 +406,23 @@ manager for production.
|
|
|
322
406
|
- `DecidioRejectedError` — a human rejected the routed action.
|
|
323
407
|
- `DecidioSuspendedError` — (durable mode) the action was routed and is parked for async approval; the process may exit and resume later. Not a failure.
|
|
324
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
|
+
- `DecidioUnsafeArgumentsError` — your call arguments cannot survive JSON with their meaning intact, so nothing was routed and nothing ran. The message names the field and the fix. Most often: `NaN`/`Infinity`, `-0`, a `Date`, `Map` or class instance, a `BigInt`, or a sparse array — anything whose serialized form would differ from the value the approval and signature cover. Pass the already-serialized form.
|
|
417
|
+
|
|
418
|
+
```js
|
|
419
|
+
try { await payInvoice(inv); }
|
|
420
|
+
catch (e) {
|
|
421
|
+
if (e instanceof DecidioHttpError && !e.retryable) { await alertOncall(e.message); throw e; }
|
|
422
|
+
if (e instanceof DecidioHttpError) { await backoff(); return retry(); }
|
|
423
|
+
throw e;
|
|
424
|
+
}
|
|
425
|
+
```
|
|
325
426
|
|
|
326
427
|
Every error raised after a decision exists carries the `decisionId`, so a suspended or
|
|
327
428
|
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,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, 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
|
+
if (typeof a.decisionId !== "string" || !a.decisionId) {
|
|
11
|
+
throw new DecidioBlockedError(`the gate answered "${a.decision}" with no usable decisionId (${JSON.stringify(a.decisionId ?? null)}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, decisionId);
|
|
12
|
+
}
|
|
13
|
+
return a;
|
|
14
|
+
}
|
|
2
15
|
export async function gateAuthorize(config, ctx) {
|
|
3
|
-
|
|
4
|
-
assertSafeTransport(config.apiUrl,
|
|
16
|
+
canonicalize(ctx, "describe(...)");
|
|
17
|
+
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
5
18
|
const f = config.fetchImpl ?? fetch;
|
|
6
19
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
7
20
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
@@ -16,19 +29,35 @@ export async function gateAuthorize(config, ctx) {
|
|
|
16
29
|
...(requesterIdentity ? { requesterIdentity } : {}), ...(resumeUrl ? { resumeUrl } : {}),
|
|
17
30
|
}),
|
|
18
31
|
});
|
|
19
|
-
if (!res.ok)
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const raw = await res.text().catch(() => "");
|
|
34
|
+
let parsed = raw;
|
|
35
|
+
try {
|
|
36
|
+
parsed = raw ? JSON.parse(raw) : undefined;
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
throw new DecidioHttpError(res.status, "/agent/authorize", parsed);
|
|
40
|
+
}
|
|
41
|
+
return assertAuthorizeVerdict(await res.json(), "unknown");
|
|
22
42
|
}
|
|
23
43
|
export async function gateConfirm(config, decisionId, result) {
|
|
24
44
|
const f = config.fetchImpl ?? fetch;
|
|
25
45
|
try {
|
|
26
|
-
assertSafeTransport(config.apiUrl,
|
|
46
|
+
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
27
47
|
}
|
|
28
48
|
catch (e) {
|
|
29
49
|
console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
|
|
30
50
|
return;
|
|
31
51
|
}
|
|
52
|
+
let reported = null;
|
|
53
|
+
let evidenceProblem;
|
|
54
|
+
try {
|
|
55
|
+
reported = canonicalize(result ?? null, "result");
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
evidenceProblem = String(e?.message ?? e);
|
|
59
|
+
console.warn(`[decidio] ${decisionId}: the action SUCCEEDED but its result could not be reported as evidence (${evidenceProblem}). Reporting an empty captured response so the execution is still recorded — map the return value with the \`evidence\` option to earn a confirming tier. The action was NOT re-run.`);
|
|
60
|
+
}
|
|
32
61
|
try {
|
|
33
62
|
const requesterIdentity = signConfirm(decisionId, config);
|
|
34
63
|
if (!requesterIdentity)
|
|
@@ -36,13 +65,19 @@ export async function gateConfirm(config, decisionId, result) {
|
|
|
36
65
|
const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
37
66
|
method: "POST",
|
|
38
67
|
headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
|
|
39
|
-
body: JSON.stringify(
|
|
68
|
+
body: JSON.stringify(buildConfirmBody(config, decisionId, reported, requesterIdentity)),
|
|
40
69
|
});
|
|
41
70
|
if (!res.ok) {
|
|
42
71
|
console.warn(`[decidio] confirm returned ${res.status} (action succeeded)`);
|
|
43
72
|
return;
|
|
44
73
|
}
|
|
45
|
-
|
|
74
|
+
const ev = readConfirmResponse(await res.json().catch(() => null), { signed: !!requesterIdentity, capturedResponse: reported });
|
|
75
|
+
if (!ev.reportRecorded)
|
|
76
|
+
console.warn(`[decidio] ${decisionId}: ${ev.diagnostic}`);
|
|
77
|
+
else if (!ev.executionConfirmed || ev.executionReportVerified === false)
|
|
78
|
+
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
|
|
79
|
+
if (evidenceProblem)
|
|
80
|
+
console.warn(`[decidio] ${decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
|
|
46
81
|
}
|
|
47
82
|
catch (e) {
|
|
48
83
|
console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
|
|
@@ -58,3 +93,35 @@ export function assertVerdict(verdict, decisionId, reason) {
|
|
|
58
93
|
throw new DecidioRejectedError(reason ?? "rejected by approver", decisionId);
|
|
59
94
|
throw new DecidioBlockedError(reason ?? "not approved", decisionId);
|
|
60
95
|
}
|
|
96
|
+
export const ENGINE_RANGES = {
|
|
97
|
+
langgraph: { pkg: "@langchain/langgraph", min: "0.2.0", lessThan: "2.0.0" },
|
|
98
|
+
temporal: { pkg: "@temporalio/workflow", min: "1.0.0", lessThan: "2.0.0" },
|
|
99
|
+
openai: { pkg: "@openai/agents", min: "0.0.1", lessThan: "2.0.0" },
|
|
100
|
+
inngest: { pkg: "inngest", min: "3.0.0", lessThan: "4.0.0" },
|
|
101
|
+
};
|
|
102
|
+
const cmp = (a, b) => {
|
|
103
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0), pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
104
|
+
for (let i = 0; i < 3; i++) {
|
|
105
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
106
|
+
return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
};
|
|
110
|
+
export function checkEngineVersion(adapter) {
|
|
111
|
+
const spec = ENGINE_RANGES[adapter];
|
|
112
|
+
if (!spec)
|
|
113
|
+
return;
|
|
114
|
+
let found;
|
|
115
|
+
try {
|
|
116
|
+
const req = createRequire(import.meta.url);
|
|
117
|
+
found = req(`${spec.pkg}/package.json`)?.version;
|
|
118
|
+
}
|
|
119
|
+
catch { }
|
|
120
|
+
if (!found)
|
|
121
|
+
return;
|
|
122
|
+
if (cmp(found, spec.min) < 0 || cmp(found, spec.lessThan) >= 0) {
|
|
123
|
+
console.warn(`[decidio] ${spec.pkg} ${found} is outside the range this BETA adapter is tested against (>=${spec.min} <${spec.lessThan}). ` +
|
|
124
|
+
`The adapter may still work, but nothing verifies it at this version. Remediation: install a supported version ` +
|
|
125
|
+
`(npm i "${spec.pkg}@>=${spec.min} <${spec.lessThan}"), or use the core guard.protect path, which is not beta and needs no engine.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { interrupt, Command } from "@langchain/langgraph";
|
|
2
2
|
import { DecidioBlockedError } from "../index.js";
|
|
3
|
-
import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict } from "./_gate.js";
|
|
3
|
+
import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion } from "./_gate.js";
|
|
4
4
|
export async function decidioGate(config, ctx, run) {
|
|
5
5
|
const auth = await gateAuthorize(config, ctx);
|
|
6
6
|
if (auth.decision === "block")
|
|
@@ -16,3 +16,4 @@ export async function decidioGate(config, ctx, run) {
|
|
|
16
16
|
export function decidioResumeCommand(body) {
|
|
17
17
|
return new Command({ resume: { verdict: body.verdict, reason: body.reason } });
|
|
18
18
|
}
|
|
19
|
+
checkEngineVersion("langgraph");
|
package/dist/adapters/openai.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { gateAuthorize } from "./_gate.js";
|
|
1
|
+
import { gateAuthorize, checkEngineVersion } from "./_gate.js";
|
|
2
2
|
export async function gateInterruptions(config, state, describe) {
|
|
3
3
|
const resolved = [];
|
|
4
4
|
const pending = [];
|
|
@@ -23,3 +23,4 @@ export function applyResume(state, item, verdict) {
|
|
|
23
23
|
else
|
|
24
24
|
state.reject(item);
|
|
25
25
|
}
|
|
26
|
+
checkEngineVersion("openai");
|
|
@@ -1,14 +1,15 @@
|
|
|
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";
|
|
3
|
+
import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict, checkEngineVersion, assertAuthorizeVerdict } from "./_gate.js";
|
|
4
4
|
export const decidioResumeSignal = defineSignal("decidioResume");
|
|
5
5
|
export function installDecidioSignal() {
|
|
6
6
|
const verdicts = new Map();
|
|
7
7
|
setHandler(decidioResumeSignal, (decisionId, verdict, reason) => { verdicts.set(decisionId, { verdict, reason }); });
|
|
8
8
|
return verdicts;
|
|
9
9
|
}
|
|
10
|
+
let confirmWarned = false;
|
|
10
11
|
export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
11
|
-
const auth = await (opts.authorize ?? (() => gateAuthorize(config, ctx)))();
|
|
12
|
+
const auth = assertAuthorizeVerdict(await (opts.authorize ?? (() => gateAuthorize(config, ctx)))(), "unknown");
|
|
12
13
|
if (auth.decision === "block")
|
|
13
14
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
14
15
|
if (auth.decision === "route") {
|
|
@@ -16,7 +17,20 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
|
16
17
|
const v = verdicts.get(auth.decisionId);
|
|
17
18
|
assertVerdict(verdictFrom(v), auth.decisionId, v.reason);
|
|
18
19
|
}
|
|
20
|
+
else if (auth.decision !== "proceed") {
|
|
21
|
+
throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)} — refusing to execute`, auth.decisionId);
|
|
22
|
+
}
|
|
19
23
|
const result = await run();
|
|
20
|
-
|
|
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
|
+
}
|
|
21
34
|
return result;
|
|
22
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;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export class DecidioUnsafeArgumentsError extends Error {
|
|
2
|
+
decisionId;
|
|
3
|
+
constructor(message, decisionId) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.decisionId = decisionId;
|
|
6
|
+
this.name = "DecidioUnsafeArgumentsError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const NOT_ROUTED = "The decision was NOT routed; nothing reached Decidio.";
|
|
10
|
+
function refuse(path, what, fix) {
|
|
11
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${path} ${what} — the approval payload must be plain JSON so that what a human approves is exactly what executes; ${fix} ${NOT_ROUTED}`);
|
|
12
|
+
}
|
|
13
|
+
function assertNoLoneSurrogate(s, p) {
|
|
14
|
+
for (let i = 0; i < s.length; i++) {
|
|
15
|
+
const c = s.charCodeAt(i);
|
|
16
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
17
|
+
const n = s.charCodeAt(i + 1);
|
|
18
|
+
if (n >= 0xdc00 && n <= 0xdfff) {
|
|
19
|
+
i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
refuse(p, `contains an unpaired surrogate (U+${c.toString(16).toUpperCase()})`, "it cannot be encoded as UTF-8, so no wire or seal can carry it — repair the string (it is usually a split emoji or truncated UTF-16).");
|
|
23
|
+
}
|
|
24
|
+
else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
25
|
+
refuse(p, `contains an unpaired surrogate (U+${c.toString(16).toUpperCase()})`, "it cannot be encoded as UTF-8, so no wire or seal can carry it — repair the string (it is usually a split emoji or truncated UTF-16).");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function canonicalize(value, path = "args") {
|
|
30
|
+
return walk(value, path, new Set());
|
|
31
|
+
}
|
|
32
|
+
function walk(v, p, seen) {
|
|
33
|
+
if (v === null)
|
|
34
|
+
return null;
|
|
35
|
+
const t = typeof v;
|
|
36
|
+
if (t === "boolean")
|
|
37
|
+
return v;
|
|
38
|
+
if (t === "string") {
|
|
39
|
+
assertNoLoneSurrogate(v, p);
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
if (t === "number") {
|
|
43
|
+
const n = v;
|
|
44
|
+
if (!Number.isFinite(n))
|
|
45
|
+
refuse(p, `is ${String(n)}`, "a non-finite number becomes null in JSON, so the approver and your function would see different values — pass a finite number or a string.");
|
|
46
|
+
if (Object.is(n, -0))
|
|
47
|
+
refuse(p, "is -0", "negative zero is indistinguishable from 0 after serialization — pass 0 if that is what you mean.");
|
|
48
|
+
return n;
|
|
49
|
+
}
|
|
50
|
+
if (t === "undefined")
|
|
51
|
+
refuse(p, "is undefined", "JSON drops or nullifies it — use null explicitly, or omit the field.");
|
|
52
|
+
if (t === "bigint")
|
|
53
|
+
refuse(p, "is a BigInt", "JSON cannot represent it — pass it as a string.");
|
|
54
|
+
if (t === "function")
|
|
55
|
+
refuse(p, "is a function", "it cannot be serialized for approval.");
|
|
56
|
+
if (t === "symbol")
|
|
57
|
+
refuse(p, "is a symbol", "it cannot be serialized for approval.");
|
|
58
|
+
const o = v;
|
|
59
|
+
if (seen.has(o))
|
|
60
|
+
refuse(p, "is part of a circular structure", "JSON cannot represent cycles — break the cycle or pass an id instead.");
|
|
61
|
+
seen.add(o);
|
|
62
|
+
try {
|
|
63
|
+
const proto = Object.getPrototypeOf(o);
|
|
64
|
+
if (Array.isArray(v)) {
|
|
65
|
+
if (proto !== Array.prototype)
|
|
66
|
+
refuse(p, "is an array subclass", "only plain arrays survive serialization with their behavior intact — pass a plain array.");
|
|
67
|
+
const arr = v;
|
|
68
|
+
const keys = Reflect.ownKeys(arr);
|
|
69
|
+
const expected = arr.length + 1;
|
|
70
|
+
if (keys.length !== expected) {
|
|
71
|
+
const extra = keys.filter((k) => k !== "length" && !(typeof k === "string" && /^\d+$/.test(k) && Number(k) < arr.length));
|
|
72
|
+
if (extra.length)
|
|
73
|
+
refuse(`${p}.${String(extra[0])}`, "is an extra property on an array", "JSON serializes arrays by index only, so it would be dropped — move it into an object.");
|
|
74
|
+
refuse(p, "is a sparse array (it has holes)", "each hole becomes null in JSON — fill the holes or pass a dense array.");
|
|
75
|
+
}
|
|
76
|
+
return arr.map((x, i) => walk(x, `${p}[${i}]`, seen));
|
|
77
|
+
}
|
|
78
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
79
|
+
const name = o?.constructor?.name;
|
|
80
|
+
refuse(p, `is ${name && name !== "Object" ? `a ${name}` : "an exotic object"}`, "only plain JSON objects survive serialization with their meaning intact (a Map becomes {}, a Date becomes a string) — convert it to plain JSON first.");
|
|
81
|
+
}
|
|
82
|
+
if (proto === null)
|
|
83
|
+
refuse(p, "is a null-prototype object", "it round-trips into an ordinary object, changing its kind — pass a plain object.");
|
|
84
|
+
if (typeof o.toJSON === "function")
|
|
85
|
+
refuse(p, "defines toJSON()", "its serialized form would differ from the value the approval and signature cover — pass the already-serialized form.");
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const key of Reflect.ownKeys(o)) {
|
|
88
|
+
if (typeof key === "symbol")
|
|
89
|
+
refuse(p, "has a symbol-keyed property", "JSON drops symbol keys — use string keys.");
|
|
90
|
+
const d = Object.getOwnPropertyDescriptor(o, key);
|
|
91
|
+
if (!("value" in d))
|
|
92
|
+
refuse(`${p}.${key}`, "is a getter/setter", "an accessor is evaluated at serialization time, so the approved value could differ from the executed one — pass a plain data property.");
|
|
93
|
+
if (!d.enumerable)
|
|
94
|
+
refuse(`${p}.${key}`, "is a non-enumerable property", "JSON drops it, so the approver would never see it — make it enumerable or remove it.");
|
|
95
|
+
assertNoLoneSurrogate(key, `${p}.${key}`);
|
|
96
|
+
Object.defineProperty(out, key, {
|
|
97
|
+
value: walk(d.value, `${p}.${key}`, seen),
|
|
98
|
+
writable: true, enumerable: true, configurable: true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
seen.delete(o);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function deepFreeze(v) {
|
|
108
|
+
if (v === null || typeof v !== "object")
|
|
109
|
+
return v;
|
|
110
|
+
for (const k of Object.keys(v))
|
|
111
|
+
deepFreeze(v[k]);
|
|
112
|
+
return Object.freeze(v);
|
|
113
|
+
}
|
|
114
|
+
export function canonicalizeArgs(args) {
|
|
115
|
+
return args.map((a, i) => deepFreeze(walk(a, `args[${i}]`, new Set())));
|
|
116
|
+
}
|
|
117
|
+
export function canonicalJson(value) {
|
|
118
|
+
if (value === null)
|
|
119
|
+
return "null";
|
|
120
|
+
if (typeof value === "boolean" || typeof value === "number")
|
|
121
|
+
return JSON.stringify(value);
|
|
122
|
+
if (typeof value === "string")
|
|
123
|
+
return JSON.stringify(value);
|
|
124
|
+
if (Array.isArray(value))
|
|
125
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
126
|
+
const keys = Object.keys(value).sort();
|
|
127
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
|
|
128
|
+
}
|