@decidio/sdk 0.6.4 → 0.7.0
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/CHANGELOG.md +167 -0
- package/README.md +103 -23
- package/dist/adapters/_gate.d.ts +27 -2
- package/dist/adapters/_gate.js +33 -2
- package/dist/adapters/langgraph.d.ts +5 -11
- package/dist/adapters/langgraph.js +57 -6
- package/dist/adapters/openai.d.ts +40 -10
- package/dist/adapters/openai.js +19 -11
- package/dist/adapters/temporal.d.ts +21 -2
- package/dist/adapters/temporal.js +18 -5
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +252 -37
- package/dist/demo-gate.js +6 -4
- package/dist/index.d.ts +36 -4
- package/dist/index.js +48 -5
- package/dist/inngest.d.ts +5 -2
- package/dist/inngest.js +23 -15
- package/dist/resume.d.ts +98 -5
- package/dist/resume.js +203 -109
- package/dist/signing.d.ts +35 -0
- package/dist/signing.js +40 -1
- package/openapi.yaml +65 -6
- package/package.json +2 -2
|
@@ -16,10 +16,27 @@ interface RunStateLike {
|
|
|
16
16
|
*
|
|
17
17
|
* const state = result.state; // or RunState from the run
|
|
18
18
|
* const { resolved, pending } = await authorizeInterruptions(config, state, describe);
|
|
19
|
-
* if (pending.length) {
|
|
19
|
+
* if (pending.length) { // suspend: park the state + each {callId, decisionId}
|
|
20
|
+
* await store.put(runId, { state: state.toString(), pending: pending.map(({ callId, decisionId }) => ({ callId, decisionId })) });
|
|
21
|
+
* return;
|
|
22
|
+
* }
|
|
20
23
|
* result = await run(agent, state); // all resolved → resume
|
|
21
|
-
* // on Decidio
|
|
22
|
-
* //
|
|
24
|
+
* // on Decidio's webhook (a wake-up): restore, find EACH parked call in the restored state by its call id,
|
|
25
|
+
* // and let Decidio decide it:
|
|
26
|
+
* // const parked = await store.get(runId); const state = await RunState.fromString(agent, parked.state);
|
|
27
|
+
* // for (const { callId, decisionId } of parked.pending) {
|
|
28
|
+
* // const item = state.getInterruptions().find((i) => i.rawItem?.callId === callId);
|
|
29
|
+
* // await applyVerifiedResume(config, state, item, decisionId, describe);
|
|
30
|
+
* // }
|
|
31
|
+
* // result = await run(agent, state);
|
|
32
|
+
*
|
|
33
|
+
* THE BINDING IS THE TOOL CALL (#4B review W2): a routed approval is bound to the described context AND
|
|
34
|
+
* the call itself - its name, call id and exact argument string (toolCallRequest). An item edited after
|
|
35
|
+
* approval, or a second identical call (another call id), never rides the approval.
|
|
36
|
+
*
|
|
37
|
+
* KNOWN LIMIT (predates 0.7.0): this door has no single-execution claim. The RunState is the host's own
|
|
38
|
+
* storage, so two concurrent resumes of the same parked state can both apply the approval and both run
|
|
39
|
+
* the tool; serialize resumes per run on your side (one worker per run id, or a lock).
|
|
23
40
|
*
|
|
24
41
|
* Duck-typed on the state/item (no hard @openai/agents import) so it stays thin.
|
|
25
42
|
*
|
|
@@ -30,9 +47,12 @@ export interface Resolved {
|
|
|
30
47
|
verdict: "approved" | "blocked";
|
|
31
48
|
decisionId: string;
|
|
32
49
|
}
|
|
50
|
+
/** `callId` (0.7.0) is the tool call the approval is bound to - find the item again by it after
|
|
51
|
+
* RunState.fromString. */
|
|
33
52
|
export interface Pending {
|
|
34
53
|
item: ToolApprovalItem;
|
|
35
54
|
decisionId: string;
|
|
55
|
+
callId: string;
|
|
36
56
|
}
|
|
37
57
|
/** THIS DOOR AUTHORIZES; IT DOES NOT CONFIRM. Named `gateInterruptions` it looked like the other
|
|
38
58
|
* `decidioGate*` functions, which all run the action and report what happened. This one cannot:
|
|
@@ -42,7 +62,7 @@ export interface Pending {
|
|
|
42
62
|
* and a failed confirmation must not look alike.
|
|
43
63
|
*
|
|
44
64
|
* proceed → state.approve(item); block → state.reject(item); route → pending (a human decides via
|
|
45
|
-
* Decidio; the host parks state.toString() + resumes via
|
|
65
|
+
* Decidio; the host parks state.toString() + resumes via applyVerifiedResume). */
|
|
46
66
|
export declare function authorizeInterruptions(config: GuardConfig, state: RunStateLike, describe: (item: ToolApprovalItem) => ApprovalContext): Promise<{
|
|
47
67
|
resolved: Resolved[];
|
|
48
68
|
pending: Pending[];
|
|
@@ -51,11 +71,21 @@ export declare function authorizeInterruptions(config: GuardConfig, state: RunSt
|
|
|
51
71
|
/** @deprecated Renamed to `authorizeInterruptions` in 0.3.0 — the old name read like the other
|
|
52
72
|
* `decidioGate*` doors, which execute and confirm. This one only ever authorized. */
|
|
53
73
|
export declare const gateInterruptions: typeof authorizeInterruptions;
|
|
54
|
-
/** Apply
|
|
74
|
+
/** Apply Decidio's answer for one routed tool call to a resumed RunState (after RunState.fromString)
|
|
75
|
+
* - VERIFIED (0.7.0). It asks Decidio: GET /agent/status/:decisionId, and the approval must be bound
|
|
76
|
+
* to THIS tool call - the commitment Decidio holds is recomputed over the call's name, call id and exact
|
|
77
|
+
* argument string plus `describe(item)` (toolCallRequest), the same binding the call was authorized
|
|
78
|
+
* with. A parked RunState is the host's own storage: an item whose arguments were edited there after a
|
|
79
|
+
* human approved, or another call handed this call's decision id, must not ride that approval.
|
|
80
|
+
*
|
|
81
|
+
* Approved and bound → state.approve(item), returns "approved". Rejected/blocked by Decidio →
|
|
82
|
+
* state.reject(item), returns that verdict. Still pending, or approved for a DIFFERENT request, or not
|
|
83
|
+
* tied to one → throws the typed refusal and the RunState is NOT modified.
|
|
55
84
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
|
|
85
|
+
* REPLACES `applyResume(state, item, verdict)`, REMOVED in 0.7.0. It applied a bare verdict string
|
|
86
|
+
* with no decision attached, so correctness depended entirely on the host routing the right verdict
|
|
87
|
+
* to the right item. It was removed rather than deprecated: a deprecated function that still approves
|
|
88
|
+
* keeps the hole open for every integration that never reads the warning, and one that refused
|
|
89
|
+
* approvals at runtime would fail AFTER a human approved - the removal fails at build time instead. */
|
|
90
|
+
export declare function applyVerifiedResume(config: GuardConfig, state: RunStateLike, item: ToolApprovalItem, decisionId: string, describe: (item: ToolApprovalItem) => ApprovalContext): Promise<"approved" | "rejected" | "blocked">;
|
|
61
91
|
export {};
|
package/dist/adapters/openai.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { gateAuthorize, checkEngineVersion } from "./_gate.js";
|
|
2
|
-
import { DecidioBlockedError } from "../index.js";
|
|
1
|
+
import { gateAuthorize, gateCheckApproval, toolCallRequest, checkEngineVersion } from "./_gate.js";
|
|
2
|
+
import { DecidioBlockedError, approvalRefusal, decisionIdProblem } from "../index.js";
|
|
3
3
|
export async function authorizeInterruptions(config, state, describe) {
|
|
4
4
|
const resolved = [];
|
|
5
5
|
const pending = [];
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const batch = state.getInterruptions().map((item) => { const ctx = describe(item); return { item, ctx, request: toolCallRequest(item, ctx) }; });
|
|
7
|
+
for (const { item, ctx, request } of batch) {
|
|
8
|
+
const auth = await gateAuthorize(config, ctx, { request });
|
|
8
9
|
if (auth.decision === "proceed") {
|
|
9
10
|
state.approve(item);
|
|
10
11
|
resolved.push({ item, verdict: "approved", decisionId: auth.decisionId });
|
|
@@ -14,22 +15,29 @@ export async function authorizeInterruptions(config, state, describe) {
|
|
|
14
15
|
resolved.push({ item, verdict: "blocked", decisionId: auth.decisionId });
|
|
15
16
|
}
|
|
16
17
|
else if (auth.decision === "route")
|
|
17
|
-
pending.push({ item, decisionId: auth.decisionId });
|
|
18
|
+
pending.push({ item, decisionId: auth.decisionId, callId: request.args[0].callId });
|
|
18
19
|
else
|
|
19
20
|
throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)}`, auth.decisionId);
|
|
20
21
|
}
|
|
21
22
|
return { resolved, pending, confirmationSupported: false };
|
|
22
23
|
}
|
|
23
24
|
export const gateInterruptions = authorizeInterruptions;
|
|
24
|
-
export function
|
|
25
|
-
|
|
25
|
+
export async function applyVerifiedResume(config, state, item, decisionId, describe) {
|
|
26
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
27
|
+
if (idProblem)
|
|
28
|
+
throw new DecidioBlockedError(`applyVerifiedResume needs the decision this item was routed as (${idProblem}) - pass the decisionId authorizeInterruptions returned for it. The RunState was NOT modified.`, typeof decisionId === "string" ? decisionId : "");
|
|
29
|
+
const ctx = describe(item);
|
|
30
|
+
const check = await gateCheckApproval(config, decisionId, ctx, toolCallRequest(item, ctx));
|
|
31
|
+
if (check.ok) {
|
|
26
32
|
state.approve(item);
|
|
27
|
-
return;
|
|
33
|
+
return "approved";
|
|
28
34
|
}
|
|
29
|
-
if (
|
|
35
|
+
if (check.code === "rejected" || check.code === "blocked") {
|
|
30
36
|
state.reject(item);
|
|
31
|
-
return;
|
|
37
|
+
return check.code;
|
|
32
38
|
}
|
|
33
|
-
|
|
39
|
+
const refusal = approvalRefusal(check);
|
|
40
|
+
refusal.message += " The RunState was NOT modified.";
|
|
41
|
+
throw refusal;
|
|
34
42
|
}
|
|
35
43
|
checkEngineVersion("openai");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type GuardConfig, type ApprovalContext, type ProtectedExecution, type EvidenceOutcome } from "../index.js";
|
|
1
|
+
import { type GuardConfig, type ApprovalContext, type ProtectedExecution, type EvidenceOutcome, type ApprovalCheck } from "../index.js";
|
|
2
2
|
import { type GateAuth } from "./_gate.js";
|
|
3
3
|
/**
|
|
4
4
|
* @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
|
|
@@ -9,20 +9,39 @@ import { type GateAuth } from "./_gate.js";
|
|
|
9
9
|
* const verdicts = installDecidioSignal(); // wires the resume signal
|
|
10
10
|
* const out = await decidioGate(config, ctx, () => createOpp(o), verdicts, {
|
|
11
11
|
* authorize: () => executeActivity(authorizeActivity, ...), // HTTP belongs in activities
|
|
12
|
+
* verify: (id) => executeActivity(verifyActivity, id), // body: return verifyApprovalActivity(config, id, ctx)
|
|
12
13
|
* confirm: (id, r) => executeActivity(confirmActivity, ...),
|
|
13
14
|
* });
|
|
14
15
|
* Decidio's webhook bridges to: handle.signal(decidioResumeSignal, decisionId, verdict, reason)
|
|
15
16
|
*
|
|
17
|
+
* THE SIGNAL IS A WAKE-UP (0.7.0). Before 0.7.0 the action ran on the signal alone, so anyone who could
|
|
18
|
+
* signal the workflow with the decisionId approved it. Now the signal only wakes the workflow; `verify`
|
|
19
|
+
* then confirms with Decidio that THIS request was approved (GET /agent/status + the request
|
|
20
|
+
* commitment) before `run()`. Still pending → keep waiting for the next signal; rejected/blocked/changed
|
|
21
|
+
* → the typed refusal. `verify` is HTTP, so it must be an ACTIVITY, never workflow code (determinism) -
|
|
22
|
+
* and it is required whenever `authorize` is.
|
|
23
|
+
*
|
|
16
24
|
* (This wiring doc ships in the .d.ts — the README points here.)
|
|
17
25
|
*/
|
|
18
26
|
export declare const decidioResumeSignal: import("@temporalio/workflow").SignalDefinition<[string, string, (string | undefined)?]>;
|
|
19
|
-
/** Call inside the workflow to wire the resume signal; returns the
|
|
27
|
+
/** Call inside the workflow to wire the resume signal; returns the signal store. Since 0.7.0 an entry is
|
|
28
|
+
* a WAKE-UP for its decision - the verdict it carries is informational and is consumed when the gate
|
|
29
|
+
* checks the approval with Decidio. */
|
|
20
30
|
export declare function installDecidioSignal(): Map<string, {
|
|
21
31
|
verdict: string;
|
|
22
32
|
reason?: string;
|
|
23
33
|
}>;
|
|
34
|
+
/** The approval check as an ACTIVITY body (0.7.0) - register an activity that returns this, unchanged,
|
|
35
|
+
* and route `verify` through it. GET /agent/status + the request commitment recomputed over `ctx`, which
|
|
36
|
+
* must be the same context the gate authorized. Never call it from workflow code (it is HTTP). */
|
|
37
|
+
export declare function verifyApprovalActivity(config: GuardConfig, decisionId: string, ctx: ApprovalContext): Promise<ApprovalCheck>;
|
|
24
38
|
export interface DecidioGateOpts<R = unknown> {
|
|
25
39
|
authorize?: () => Promise<GateAuth>;
|
|
40
|
+
/** THE APPROVAL CHECK, run after the resume signal and before `run()` (0.7.0). It is HTTP, so route it
|
|
41
|
+
* through an ACTIVITY whose body is `return verifyApprovalActivity(config, decisionId, ctx)` - the same
|
|
42
|
+
* `ctx` this gate authorized - and return that value unchanged. Required whenever `authorize` is
|
|
43
|
+
* supplied; without either it defaults to a direct call (tests only - a workflow must not do I/O). */
|
|
44
|
+
verify?: (decisionId: string) => Promise<ApprovalCheck>;
|
|
26
45
|
/** Reports through YOUR activity. Return the gate's confirm response body (validated with
|
|
27
46
|
* `readConfirmResponse`) and it becomes the outcome; return nothing and the outcome says the SDK could
|
|
28
47
|
* not read back what Decidio recorded (prodsec V035-03: a returned outcome used to be discarded). */
|
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
import { condition, defineSignal, setHandler } from "@temporalio/workflow";
|
|
2
|
-
import { DecidioBlockedError } from "../index.js";
|
|
3
|
-
import { gateAuthorize, gateConfirm,
|
|
2
|
+
import { DecidioBlockedError, approvalRefusal } from "../index.js";
|
|
3
|
+
import { gateAuthorize, gateConfirm, gateCheckApproval, assertApprovalCheckShape, 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
|
+
export function verifyApprovalActivity(config, decisionId, ctx) {
|
|
11
|
+
return gateCheckApproval(config, decisionId, ctx);
|
|
12
|
+
}
|
|
10
13
|
let confirmWarned = false;
|
|
11
14
|
export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
|
|
15
|
+
if (opts.authorize && !opts.verify) {
|
|
16
|
+
throw new Error("[decidio] temporal: `authorize` runs in your activity, so the approval check must too - pass `verify: (decisionId) => executeActivity(verifyActivity, decisionId)`, where the activity returns verifyApprovalActivity(config, decisionId, ctx) (from @decidio/sdk/temporal) unchanged. Since 0.7.0 a resume signal only wakes the workflow; the approval is confirmed with Decidio before your action runs. Nothing was routed or executed.");
|
|
17
|
+
}
|
|
12
18
|
const auth = assertAuthorizeVerdict(await (opts.authorize ?? (() => gateAuthorize(config, ctx)))(), "unknown");
|
|
13
19
|
if (auth.decision === "block")
|
|
14
20
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
15
21
|
if (auth.decision === "route") {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
const verify = opts.verify ?? ((decisionId) => gateCheckApproval(config, decisionId, ctx));
|
|
23
|
+
for (;;) {
|
|
24
|
+
await condition(() => verdicts.has(auth.decisionId));
|
|
25
|
+
verdicts.delete(auth.decisionId);
|
|
26
|
+
const check = assertApprovalCheckShape(await verify(auth.decisionId), auth.decisionId);
|
|
27
|
+
if (check.ok)
|
|
28
|
+
break;
|
|
29
|
+
if (check.code !== "pending")
|
|
30
|
+
throw approvalRefusal(check);
|
|
31
|
+
}
|
|
19
32
|
}
|
|
20
33
|
else if (auth.decision !== "proceed") {
|
|
21
34
|
throw new DecidioBlockedError(`unhandled authorization verdict ${JSON.stringify(auth.decision)} — refusing to execute`, auth.decisionId);
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
export type ApprovalAnswer = "approve" | "reject" | "again";
|
|
3
3
|
export declare function approvalAnswer(answer: unknown): ApprovalAnswer;
|
|
4
|
+
/** The issuer store's key for a Decidio address: its origin (scheme + lowercased host + non-default port), computed by a
|
|
5
|
+
* RESTRICTED parser that FAILS CLOSED, identically in both SDKs. It deliberately does NOT reproduce the browser URL's
|
|
6
|
+
* canonicalisation: `new URL("https://%64ecidio-api.onrender.com").origin` is the real host, and keying by that let a
|
|
7
|
+
* planted store entry under a percent-encoded / IDN / numeric-shorthand / backslash spelling be silently trusted AS the
|
|
8
|
+
* real host (the Python twin, on urlsplit, never decoded these, so the two SDKs also disagreed). Now: only a bare
|
|
9
|
+
* http(s)://, an ASCII-label host or a bracketed real IP literal is keyed as an origin; anything the parser is not sure
|
|
10
|
+
* it can canonicalise the same in both languages is keyed as its trimmed lowercased self, which cannot equal a real
|
|
11
|
+
* origin and so is never mistaken for one. Exported for the smokes; the Python twin _issuer_store_key answers the same. */
|
|
12
|
+
export declare function issuerStoreKey(url: string): string;
|
|
4
13
|
/** The agent id `init` uses when none is given: this folder's name (or the package name), slugified. "my-agent" told a
|
|
5
14
|
* tester nothing; the folder they are in is the agent they are building (founder ruling 2026-09-06). */
|
|
6
15
|
export declare function defaultAgentId(dir?: string): string;
|