@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.
@@ -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) { await store.put(decisionId, state.toString()); return; } // suspend
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 approval webhook: state = await RunState.fromString(agent, parked);
22
- * // applyResume(state, item, verdict); result = await run(agent, state);
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 applyResume). */
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 a Decidio verdict to a resumed RunState (after RunState.fromString).
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
- * VALIDATES BEFORE MUTATING. `else state.reject(item)` treated every unrecognised string as a
57
- * rejection, so a garbled payload, a version-skew verdict or an empty string became a rejection
58
- * that a caller could not tell apart from a human deciding no — and the RunState was already
59
- * mutated by the time anyone could look. The recognised set is exactly the one `resume` uses. */
60
- export declare function applyResume(state: RunStateLike, item: ToolApprovalItem, verdict: string): void;
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 {};
@@ -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
- for (const item of state.getInterruptions()) {
7
- const auth = await gateAuthorize(config, describe(item));
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 applyResume(state, item, verdict) {
25
- if (verdict === "approved" || verdict === "auto_approved") {
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 (verdict === "rejected" || verdict === "blocked") {
35
+ if (check.code === "rejected" || check.code === "blocked") {
30
36
  state.reject(item);
31
- return;
37
+ return check.code;
32
38
  }
33
- throw new DecidioBlockedError(`unrecognised resume verdict ${JSON.stringify(verdict)} — refusing to apply it. This SDK understands approved / auto_approved / rejected / blocked; anything else is a version skew, and guessing would turn "I do not understand" into a decision. The RunState was NOT modified.`, "");
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 verdict store. */
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, verdictFrom, assertVerdict, checkEngineVersion, assertAuthorizeVerdict } from "./_gate.js";
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
- await condition(() => verdicts.has(auth.decisionId));
17
- const v = verdicts.get(auth.decisionId);
18
- assertVerdict(verdictFrom(v), auth.decisionId, v.reason);
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;