@decidio/sdk 0.1.5 → 0.1.6

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 CHANGED
@@ -192,8 +192,21 @@ also allow-list resume hosts (default-deny). That's operator territory: if you r
192
192
  Decidio (or we run a pilot with you), the webhook is set up then. Against the hosted sandbox,
193
193
  use the worker — a webhook secret your server never learned would (correctly) fail closed.
194
194
 
195
- Either way, re-execution is **single-use** — the parked entry is claimed on completion, so a
196
- duplicate signal can't double-write.
195
+ Either way, re-execution is **single-use on the happy path**: the parked entry is claimed before
196
+ execution and deleted on success, so a duplicate signal finds nothing to replay. Honest edge — if
197
+ your action succeeds but the process dies *before* the entry is cleared, the claim can be retried
198
+ (robust crash-recovery of stale claims is on the roadmap), so make your downstream action
199
+ idempotent if you need exactly-once under crashes.
200
+
201
+ **Payload integrity.** When a routed action is parked, the SDK signs its arguments (bound to the
202
+ decision id) with your agent key; on resume it **refuses to execute** if the parked payload was
203
+ modified, its signature stripped, or the file swapped into another decision's slot. That defends
204
+ against tampering by anything lacking your agent's key — note a *fully compromised agent host*
205
+ can bypass the guard entirely (it holds both the key and the function), so the durable guarantee
206
+ that closes that gap is server-sealed argument binding, auditable in the receipt itself, which is
207
+ on the roadmap. **Upgrading from ≤0.1.5:** entries parked by an older version carry no signature
208
+ and are refused once your agent has a key — drain pending approvals before upgrading, or delete
209
+ the parked files and re-run those actions.
197
210
 
198
211
  ### Already on a durable engine? Use its native wait
199
212
 
@@ -7,7 +7,5 @@ export interface GateAuth {
7
7
  }
8
8
  export declare function gateAuthorize(config: GuardConfig, ctx: ApprovalContext): Promise<GateAuth>;
9
9
  export declare function gateConfirm(config: GuardConfig, decisionId: string, result: unknown): Promise<void>;
10
- /** Normalize a resume payload ({verdict} | "approved" | ...) to a verdict string. */
11
10
  export declare function verdictFrom(payload: any): string;
12
- /** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
13
11
  export declare function assertVerdict(verdict: string, decisionId: string, reason?: string): void;
@@ -1,15 +1,7 @@
1
- // Shared adapter gate logic (TS) — the thin-adapter rule in code. Every engine adapter
2
- // reuses these gate calls + verdict handling; only the suspend/resume translation
3
- // differs. Pure/testable so the offline smoke covers our logic; the engine-primitive
4
- // call in each adapter is a thin one-liner verified by typecheck against real types.
5
1
  import { DecidioBlockedError, DecidioRejectedError, signBoundProof, signConfirm } from "../index.js";
6
2
  export async function gateAuthorize(config, ctx) {
7
3
  const f = config.fetchImpl ?? fetch;
8
- // Fall back to the resume controller's URL (matches the core wrapper) so an adapter
9
- // decision carries a callback URL even when only resume.resumeUrl was set.
10
4
  const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
11
- // Sign the bound identity proof like the core wrapper, so an adapter-path agent can PROVE
12
- // its identity and auto-approve (not just route). No key → unsigned → server routes.
13
5
  const requesterIdentity = (config.agentKey && config.agentDid)
14
6
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
15
7
  : undefined;
@@ -29,10 +21,7 @@ export async function gateAuthorize(config, ctx) {
29
21
  export async function gateConfirm(config, decisionId, result) {
30
22
  const f = config.fetchImpl ?? fetch;
31
23
  try {
32
- // Sign the confirm too (bound to {action:"confirm", scope:decisionId}) so the adapter path
33
- // earns application_confirmed; without a key the server caps it at agent_asserted.
34
24
  const requesterIdentity = signConfirm(decisionId, config);
35
- // Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch).
36
25
  if (!requesterIdentity)
37
26
  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.`);
38
27
  const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
@@ -40,8 +29,6 @@ export async function gateConfirm(config, decisionId, result) {
40
29
  headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
41
30
  body: JSON.stringify({ decisionId, capturedResponse: result ?? null, source: "wrapper", ...(requesterIdentity ? { requesterIdentity } : {}) }),
42
31
  });
43
- // fetch only rejects on network error — surface a non-2xx (auth/500) too. Best-effort:
44
- // log, don't throw (the action already happened).
45
32
  if (!res.ok)
46
33
  console.warn(`[decidio] confirm returned ${res.status} (action succeeded)`);
47
34
  }
@@ -49,11 +36,9 @@ export async function gateConfirm(config, decisionId, result) {
49
36
  console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
50
37
  }
51
38
  }
52
- /** Normalize a resume payload ({verdict} | "approved" | ...) to a verdict string. */
53
39
  export function verdictFrom(payload) {
54
40
  return (payload && typeof payload === "object" ? payload.verdict : payload) ?? "";
55
41
  }
56
- /** Throw the right error for a non-approving verdict; return for approved/auto_approved. */
57
42
  export function assertVerdict(verdict, decisionId, reason) {
58
43
  if (verdict === "approved" || verdict === "auto_approved")
59
44
  return;
@@ -1,9 +1,6 @@
1
1
  import { Command } from "@langchain/langgraph";
2
2
  import { type GuardConfig, type ApprovalContext } from "../index.js";
3
- /** Gate `run()` behind Decidio inside a LangGraph node. */
4
3
  export declare function decidioGate<R>(config: GuardConfig, ctx: ApprovalContext, run: () => Promise<R>): Promise<R>;
5
- /** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
6
- * maps decisionId → thread_id and passes this to graph.invoke/stream. */
7
4
  export declare function decidioResumeCommand(body: {
8
5
  verdict?: string;
9
6
  reason?: string;
@@ -1,19 +1,11 @@
1
- // @decidio/sdk/langgraph — durable suspend via LangGraph's native `interrupt()`.
2
- // `interrupt` finds the running graph itself (no handle needed), so the gate drops into
3
- // a node: authorize → on route, interrupt() durably pauses (the checkpointer persists;
4
- // the process can die) → on resume (Command(resume={verdict})) run the agent's own
5
- // action + confirm. Decidio is the approval system; the host bridges its webhook to the
6
- // graph resume (decidioResumeCommand). Requires @langchain/langgraph (optional peer dep).
7
1
  import { interrupt, Command } from "@langchain/langgraph";
8
2
  import { DecidioBlockedError } from "../index.js";
9
3
  import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict } from "./_gate.js";
10
- /** Gate `run()` behind Decidio inside a LangGraph node. */
11
4
  export async function decidioGate(config, ctx, run) {
12
5
  const auth = await gateAuthorize(config, ctx);
13
6
  if (auth.decision === "block")
14
7
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
15
8
  if (auth.decision === "route") {
16
- // Durable pause. On resume, interrupt() returns the Command(resume=...) payload.
17
9
  const resumed = interrupt({ decidio: { decisionId: auth.decisionId, action: ctx.action, amount: ctx.amount, scope: ctx.scope } });
18
10
  assertVerdict(verdictFrom(resumed), auth.decisionId, resumed?.reason);
19
11
  }
@@ -21,8 +13,6 @@ export async function decidioGate(config, ctx, run) {
21
13
  await gateConfirm(config, auth.decisionId, result);
22
14
  return result;
23
15
  }
24
- /** Translate Decidio's signed resume webhook into the LangGraph resume Command. The host
25
- * maps decisionId → thread_id and passes this to graph.invoke/stream. */
26
16
  export function decidioResumeCommand(body) {
27
17
  return new Command({ resume: { verdict: body.verdict, reason: body.reason } });
28
18
  }
@@ -14,13 +14,9 @@ export interface Pending {
14
14
  item: ToolApprovalItem;
15
15
  decisionId: string;
16
16
  }
17
- /** Ask Decidio for each pending tool-approval on a RunState.
18
- * proceed → state.approve(item); block → state.reject(item); route → pending (a human
19
- * decides via Decidio; the host parks state.toString() + resumes via applyResume). */
20
17
  export declare function gateInterruptions(config: GuardConfig, state: RunStateLike, describe: (item: ToolApprovalItem) => ApprovalContext): Promise<{
21
18
  resolved: Resolved[];
22
19
  pending: Pending[];
23
20
  }>;
24
- /** Apply a Decidio verdict to a resumed RunState (after RunState.fromString). */
25
21
  export declare function applyResume(state: RunStateLike, item: ToolApprovalItem, verdict: string): void;
26
22
  export {};
@@ -1,23 +1,4 @@
1
- // @decidio/sdk/openai — gate OpenAI Agents SDK tool-approval interruptions through
2
- // Decidio, with durable suspend via the SDK's native RunState serialization.
3
- //
4
- // The Agents loop surfaces tool approvals on the RunState (state.getInterruptions());
5
- // you state.approve(item)/reject(item) then resume with run(agent, state). RunState
6
- // serializes (state.toString() / RunState.fromString(agent, s)) — that's the durability:
7
- // park the serialized state agent-side on `route`, resume on Decidio's approval.
8
- //
9
- // const state = result.state; // or RunState from the run
10
- // const { resolved, pending } = await gateInterruptions(config, state, describe);
11
- // if (pending.length) { await store.put(decisionId, state.toString()); return; } // suspend
12
- // result = await run(agent, state); // all resolved → resume
13
- // // on Decidio approval webhook: state = await RunState.fromString(agent, parked);
14
- // // applyResume(state, item, verdict); result = await run(agent, state);
15
- //
16
- // Duck-typed on the state/item (no hard @openai/agents import) so it stays thin.
17
1
  import { gateAuthorize } from "./_gate.js";
18
- /** Ask Decidio for each pending tool-approval on a RunState.
19
- * proceed → state.approve(item); block → state.reject(item); route → pending (a human
20
- * decides via Decidio; the host parks state.toString() + resumes via applyResume). */
21
2
  export async function gateInterruptions(config, state, describe) {
22
3
  const resolved = [];
23
4
  const pending = [];
@@ -36,7 +17,6 @@ export async function gateInterruptions(config, state, describe) {
36
17
  }
37
18
  return { resolved, pending };
38
19
  }
39
- /** Apply a Decidio verdict to a resumed RunState (after RunState.fromString). */
40
20
  export function applyResume(state, item, verdict) {
41
21
  if (verdict === "approved" || verdict === "auto_approved")
42
22
  state.approve(item);
@@ -1,7 +1,6 @@
1
1
  import { type GuardConfig, type ApprovalContext } from "../index.js";
2
2
  import { type GateAuth } from "./_gate.js";
3
3
  export declare const decidioResumeSignal: import("@temporalio/workflow").SignalDefinition<[string, string, (string | undefined)?]>;
4
- /** Call inside the workflow to wire the resume signal; returns the verdict store. */
5
4
  export declare function installDecidioSignal(): Map<string, {
6
5
  verdict: string;
7
6
  reason?: string;
@@ -1,19 +1,7 @@
1
- // @decidio/sdk/temporal — durable suspend via Temporal's native `condition()` + a
2
- // Decidio resume signal. Strongest durability (the workflow survives worker restarts for
3
- // days); the agent's action runs on resume. Requires @temporalio/workflow (optional peer).
4
- //
5
- // Inside your workflow:
6
- // const verdicts = installDecidioSignal(); // wires the resume signal
7
- // const out = await decidioGate(config, ctx, () => createOpp(o), verdicts, {
8
- // authorize: () => executeActivity(authorizeActivity, ...), // HTTP belongs in activities
9
- // confirm: (id, r) => executeActivity(confirmActivity, ...),
10
- // });
11
- // Decidio's webhook bridges to: handle.signal(decidioResumeSignal, decisionId, verdict, reason)
12
1
  import { condition, defineSignal, setHandler } from "@temporalio/workflow";
13
2
  import { DecidioBlockedError } from "../index.js";
14
3
  import { gateAuthorize, gateConfirm, verdictFrom, assertVerdict } from "./_gate.js";
15
4
  export const decidioResumeSignal = defineSignal("decidioResume");
16
- /** Call inside the workflow to wire the resume signal; returns the verdict store. */
17
5
  export function installDecidioSignal() {
18
6
  const verdicts = new Map();
19
7
  setHandler(decidioResumeSignal, (decisionId, verdict, reason) => { verdicts.set(decisionId, { verdict, reason }); });
@@ -24,7 +12,7 @@ export async function decidioGate(config, ctx, run, verdicts, opts = {}) {
24
12
  if (auth.decision === "block")
25
13
  throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
26
14
  if (auth.decision === "route") {
27
- await condition(() => verdicts.has(auth.decisionId)); // durable wait on the signal
15
+ await condition(() => verdicts.has(auth.decisionId));
28
16
  const v = verdicts.get(auth.decisionId);
29
17
  assertVerdict(verdictFrom(v), auth.decisionId, v.reason);
30
18
  }
package/dist/cli.js CHANGED
@@ -1,23 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // @decidio/sdk CLI — the on-ramp. Run via `npx @decidio/sdk <cmd>` (or tsx in-repo).
3
- // init [agentId] sign in, register the agent, mint its API token, write .env
4
- // doctor check config + connectivity + durability readiness
5
- // approvals list pending agent approvals; `approvals approve|reject <id> [reason]`
6
- // receipt <id> download a sealed decision's Authority Receipt to a .json file
7
- // dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
8
- //
9
- // TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
10
- // workspace (init's register+mint, approvals, receipt) need the owner's SESSION token — acquired
11
- // interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
12
- // What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
13
- // needs (it can call /agent/* and nothing else). A session-capable secret must never live in
14
- // an agent host's .env — that was the pre-publish design's hole.
15
- //
16
- // MESSAGE DISCIPLINE (founder bar 2026-08-29, from the Codex acceptance run): every failure
17
- // tells the developer WHAT happened, WHY, and the EXACT next command. Every success says what
18
- // changed, where, and what to do next. No stack traces for expected failures; no green ✔ for a
19
- // no-op (the replay-approve bug: the server's idempotent alreadyResolved was reported as a
20
- // fresh success — an authority CLI may never dress a no-op as an action).
21
2
  import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
22
3
  import { randomBytes } from "node:crypto";
23
4
  import { createServer } from "node:http";
@@ -34,8 +15,6 @@ const VERSION = (() => {
34
15
  return "unknown";
35
16
  }
36
17
  })();
37
- // ---- environment: load the CWD .env FIRST (Codex finding: init writes .env, but the next
38
- // command ignored it and pointed at localhost). Process env always wins — .env only fills gaps.
39
18
  function readEnvFile() {
40
19
  const out = {};
41
20
  if (existsSync(".env"))
@@ -47,9 +26,6 @@ function readEnvFile() {
47
26
  return out;
48
27
  }
49
28
  for (const [k, v] of Object.entries(readEnvFile())) {
50
- // NEVER autoload the session token from disk: the whole design splits session (memory-only)
51
- // from the floor-limited agent token (.env). Silently honoring a parked session token would
52
- // reward exactly the credential-at-rest the split exists to prevent (guard catch 2026-08-29).
53
29
  if (k === "DECIDIO_SESSION_TOKEN") {
54
30
  console.log("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
55
31
  continue;
@@ -57,17 +33,11 @@ for (const [k, v] of Object.entries(readEnvFile())) {
57
33
  if (process.env[k] === undefined)
58
34
  process.env[k] = v;
59
35
  }
60
- // `quickstart` is the zero-config guided tour: with no DECIDIO_API_URL anywhere it targets the
61
- // hosted sandbox (and says so); every other command keeps the localhost dev default.
62
36
  const IS_QUICKSTART = process.argv[2] === "quickstart";
63
37
  const API = (process.env.DECIDIO_API_URL ?? (IS_QUICKSTART ? "https://decidio-api.onrender.com" : "http://localhost:4000")).replace(/\/$/, "");
64
- // Token is read AT CALL TIME, not captured at import: init mints a fresh agent token mid-run and
65
- // its closing doctor must probe with THAT, not the pre-init env (round-3 Codex catch — the stale
66
- // capture made init's own doctor print "token: REJECTED" seconds after a successful mint).
67
38
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
68
39
  const ENV_PATH = ".env";
69
40
  const log = (...a) => console.log(...a);
70
- /** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
71
41
  async function api(path, init = {}, bearer) {
72
42
  const auth = bearer ?? TOKEN();
73
43
  let res;
@@ -102,10 +72,8 @@ function upsertEnv(updates) {
102
72
  const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
103
73
  writeFileSync(ENV_PATH, body);
104
74
  }
105
- /** Keep secrets out of git without nagging: append the two entries to .gitignore when they're
106
- * not already covered. Creating the file in a non-git directory is harmless. */
107
75
  function ensureGitignore() {
108
- const entries = [".env", ".decidio-pending/"];
76
+ const entries = [".env", ".env.quickstart", ".decidio-pending/"];
109
77
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
110
78
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
111
79
  if (!missing.length)
@@ -113,12 +81,10 @@ function ensureGitignore() {
113
81
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
114
82
  log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
115
83
  }
116
- // ---- interactive input (session sign-in) ---------------------------------------------------
117
84
  function ask(question, mute = false) {
118
85
  return new Promise((resolve) => {
119
86
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
120
87
  if (mute) {
121
- // Hide password echo: after the prompt renders, swallow subsequent output writes.
122
88
  const anyRl = rl;
123
89
  const write = anyRl._writeToOutput?.bind(rl);
124
90
  anyRl._writeToOutput = (s) => { if (s.includes(question))
@@ -128,14 +94,9 @@ function ask(question, mute = false) {
128
94
  process.stdout.write("\n"); resolve(answer.trim()); });
129
95
  });
130
96
  }
131
- /** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
132
- * email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
133
- * password-less accounts the welcome flow creates). Never persisted. */
134
97
  async function acquireSession(purpose) {
135
98
  if (process.env.DECIDIO_SESSION_TOKEN)
136
99
  return process.env.DECIDIO_SESSION_TOKEN;
137
- // Same rule the server applies to resume URLs: credentials never ride plaintext http to a
138
- // non-loopback host. Refuse before prompting, not after collecting a password.
139
100
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
140
101
  log(`✘ refusing to send sign-in credentials over plaintext http to ${API}.`);
141
102
  log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
@@ -179,14 +140,10 @@ async function acquireSession(purpose) {
179
140
  }
180
141
  async function cmdInit(agentId) {
181
142
  log(`Registering agent "${agentId}" with Decidio at ${API}…`);
182
- // CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
183
- // did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
184
143
  const existingKey = readEnvFile().DECIDIO_AGENT_KEY;
185
144
  const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || readEnvFile().DECIDIO_SOURCE_SYSTEM;
186
145
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
187
146
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
188
- // First try whatever bearer the env already carries (the local-dev / operator path — a static
189
- // admin token, or dev-open auth). On 401/403 fall through to the self-serve session flow.
190
147
  let session = null;
191
148
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
192
149
  if (r.status === 401 || r.status === 403) {
@@ -197,10 +154,6 @@ async function cmdInit(agentId) {
197
154
  }
198
155
  let did, privateKey;
199
156
  if (r.status === 409) {
200
- // Already registered to a server-held did. WITHOUT the original private key on this machine
201
- // this is a HARD STOP, not a degraded continue (round-3 Codex catch: init used to write a
202
- // keyless .env and print happy-path next steps — every request then routed unproven and
203
- // execution evidence silently capped at agent_asserted).
204
157
  did = r.body?.did;
205
158
  if (existingKey) {
206
159
  log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it with the DECIDIO_AGENT_KEY already in .env.`);
@@ -227,10 +180,6 @@ async function cmdInit(agentId) {
227
180
  log(` Fix the cause above and re-run — init is safe to repeat.`);
228
181
  return 1;
229
182
  }
230
- // Mint the runtime credential: the floor-limited AGENT token (can call /agent/* only). This is
231
- // what .env gets — the session token stays in memory and dies with this process. On boxes
232
- // without the tester-lab fence (plain local dev) the mint endpoint refuses; that's fine —
233
- // local dev auths with its existing env and we say so instead of failing the init.
234
183
  let agentToken, workspaceId;
235
184
  const mintBearer = session ?? TOKEN();
236
185
  if (mintBearer) {
@@ -256,15 +205,12 @@ async function cmdInit(agentId) {
256
205
  if (did)
257
206
  envOut.DECIDIO_AGENT_DID = did;
258
207
  if (privateKey)
259
- envOut.DECIDIO_AGENT_KEY = privateKey; // written locally; never left this host
208
+ envOut.DECIDIO_AGENT_KEY = privateKey;
260
209
  if (agentToken)
261
- envOut.DECIDIO_API_TOKEN = agentToken; // the FLOOR-LIMITED token — never the session
210
+ envOut.DECIDIO_API_TOKEN = agentToken;
262
211
  if (workspaceId)
263
212
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
264
213
  upsertEnv(envOut);
265
- // The closing doctor (and anything else this process does) must see what init just wrote —
266
- // OVERWRITE process.env with the fresh values (round-3 Codex catch: doctor probed with the
267
- // stale pre-init token and reported the mint it just watched as REJECTED).
268
214
  for (const [k, v] of Object.entries(envOut))
269
215
  process.env[k] = v;
270
216
  log(`✔ wrote ${ENV_PATH} — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
@@ -305,27 +251,17 @@ async function cmdDoctor() {
305
251
  line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
306
252
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
307
253
  line(!!secret, secret ? "webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)" : "webhook secret: MISSING — the resume webhook (operator deployments) would fail closed; init generates one");
308
- // Signing key — REQUIRED to be ELIGIBLE for auto-approve. Whether anything auto-approves is
309
- // the workspace policy's call, not this key's (Codex finding: the old wording claimed
310
- // "auto-approve is in effect" while deny-by-default routed everything).
311
254
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
312
255
  if (signing)
313
256
  log("✔ signing key: set — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
314
257
  else
315
258
  log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
316
- // Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
317
- // wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
318
- // /api/records then classifies the token.
319
259
  const h = await api("/api/health", {}, "");
320
260
  line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
321
261
  const rec = await api("/api/records");
322
262
  if (rec.status >= 200 && rec.status < 300)
323
263
  log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
324
264
  else if (rec.status === 401) {
325
- // A 401 off the agent floor could also mean an INVALID token (Codex catch: don't
326
- // false-green expiry). Disambiguate with a floor probe: a valid agent token gets 404 for an
327
- // unknown decision (authorized, not found); an invalid one gets 401 again. A rejection here
328
- // flips the overall verdict — "all green" after a red line was round 3's other doctor bug.
329
265
  const probe = await api("/agent/status/doctor-scope-probe");
330
266
  if (probe.status === 404)
331
267
  log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
@@ -340,8 +276,6 @@ async function cmdDoctor() {
340
276
  return ok ? 0 : 1;
341
277
  }
342
278
  async function cmdApprovals(sub, id, reason) {
343
- // Approvals are a HUMAN act on the workspace — session scope, never the agent token. If the
344
- // env's token is agent-floor (401 on /api/*), sign in interactively; memory-only as always.
345
279
  const jsonMode = sub === "--json";
346
280
  if (jsonMode)
347
281
  sub = undefined;
@@ -360,9 +294,6 @@ async function cmdApprovals(sub, id, reason) {
360
294
  }
361
295
  const r = await api(`/api/decisions/${encodeURIComponent(id)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
362
296
  if (r.status >= 200 && r.status < 300) {
363
- // HONESTY over green checkmarks (Codex finding: the server resolves each decision exactly
364
- // once and answers replays idempotently — a replay is a NO-OP and must read as one, never
365
- // as a fresh approval). Non-zero exit so scripts can tell the difference.
366
297
  if (r.body?.alreadyResolved) {
367
298
  log(`• ${id} was already resolved — nothing changed.`);
368
299
  log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
@@ -392,7 +323,6 @@ async function cmdApprovals(sub, id, reason) {
392
323
  const cards = r.body?.cards ?? [];
393
324
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
394
325
  if (jsonMode) {
395
- // Stable machine output (round-3 ask): agents scripting the queue parse this, not prose.
396
326
  log(JSON.stringify(pending.map((c) => ({ decisionId: c.id, action: c.agentRequest?.action ?? null, amount: c.agentRequest?.amount ?? null, title: c.title ?? null })), null, 2));
397
327
  return 0;
398
328
  }
@@ -418,8 +348,6 @@ async function cmdReceipt(id, jsonMode = false) {
418
348
  log(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
419
349
  return 2;
420
350
  }
421
- // Receipts are workspace data — session scope, like approvals. (The record-detail /verify
422
- // route carries the credential; the plain record route does not — Codex finding.)
423
351
  let bearer;
424
352
  let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
425
353
  if (r.status === 401 || r.status === 403) {
@@ -439,11 +367,18 @@ async function cmdReceipt(id, jsonMode = false) {
439
367
  return 1;
440
368
  }
441
369
  const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
442
- writeFileSync(name, JSON.stringify(r.body.credential, null, 2) + "\n");
443
- log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
444
- // The credential seals at DECISION time (immutable), so execution evidence that arrived later
445
- // lives on the record — surface the CURRENT tier here so the reader doesn't need a second
446
- // lookup to see it (Codex catch).
370
+ const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
371
+ if (existsSync(name)) {
372
+ if (readFileSync(name, "utf8") !== nextBytes) {
373
+ log(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
374
+ return 1;
375
+ }
376
+ log(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
377
+ }
378
+ else {
379
+ writeFileSync(name, nextBytes);
380
+ log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
381
+ }
447
382
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
448
383
  const conf = detail.body?.record?.confirmation;
449
384
  if (jsonMode) {
@@ -452,19 +387,12 @@ async function cmdReceipt(id, jsonMode = false) {
452
387
  }
453
388
  if (conf?.status)
454
389
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
455
- // Emit EXACTLY the deterministic command we document (round-4 catch: the README and this hint
456
- // disagreed, and bare npx re-probes the registry) — the verifier ships INSIDE this package.
457
390
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
458
391
  log(` npx @decidio/sdk verify ${name}`);
459
392
  log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
460
393
  log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
461
394
  return 0;
462
395
  }
463
- // ---- verify (embedded) ----------------------------------------------------------------------
464
- /** Duplicate-key-refusing parse — the SAME semantics as the decidio-verify CLI (RFC 8785 §3.1:
465
- * duplicate keys make different parsers disagree on which value is real; refuse instead).
466
- * Conformance-locked: the cross-language runner asserts this door refuses the same vectors for
467
- * the same reasons as decidio-verify and python -m decidio.verify. */
468
396
  function parseReceiptStrict(text, file) {
469
397
  const value = JSON.parse(text);
470
398
  const stack = [];
@@ -514,6 +442,10 @@ function parseReceiptStrict(text, file) {
514
442
  }
515
443
  function cmdVerify(argsIn) {
516
444
  const issIdx = argsIn.indexOf("--issuer");
445
+ if (issIdx >= 0 && (argsIn[issIdx + 1] === undefined || argsIn[issIdx + 1].startsWith("--"))) {
446
+ log("✘ --issuer needs a did:key value.");
447
+ return 2;
448
+ }
517
449
  const expectedIssuer = issIdx >= 0 ? argsIn[issIdx + 1] : undefined;
518
450
  const files = argsIn.filter((a, i) => !a.startsWith("--") && !(issIdx >= 0 && i === issIdx + 1));
519
451
  if (!files.length) {
@@ -544,7 +476,6 @@ function cmdVerify(argsIn) {
544
476
  }
545
477
  return allOk ? 0 : 1;
546
478
  }
547
- // ---- quickstart (the guided two-minute tour) ------------------------------------------------
548
479
  async function cmdQuickstart() {
549
480
  const t0 = Date.now();
550
481
  log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
@@ -553,9 +484,6 @@ async function cmdQuickstart() {
553
484
  const session = await acquireSession("The quickstart");
554
485
  if (!session)
555
486
  return 1;
556
- // 1. A collision-proof agent, registered fresh every run (round-4 ask: idempotent sample
557
- // registration). Key generated locally; floor-limited token minted; .env written so the
558
- // take-away snippet works in this directory afterward.
559
487
  const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
560
488
  log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
561
489
  const { did, privateKeyBase64 } = generateKeypair();
@@ -574,9 +502,6 @@ async function cmdQuickstart() {
574
502
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
575
503
  DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "", DECIDIO_WEBHOOK_SECRET: readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex"),
576
504
  };
577
- // NEVER clobber a real agent's key (guard catch: keys never leave this machine — overwriting
578
- // .env's DECIDIO_AGENT_KEY orphans that agent irrecoverably). A directory that already holds
579
- // one gets a TOUR-SCOPED env file instead.
580
505
  let envFile = ENV_PATH;
581
506
  if (readEnvFile().DECIDIO_AGENT_KEY) {
582
507
  envFile = ".env.quickstart";
@@ -589,11 +514,11 @@ async function cmdQuickstart() {
589
514
  ensureGitignore();
590
515
  log(` ✔ key generated locally (public half registered) · floor-limited token minted · ${envFile} written`);
591
516
  log("");
592
- // 2. Gate a demo action — the same one line you'd wrap your real function with.
593
- log(`② Gating a demo action with ONE line — guard.protect(payInvoice, …):`);
517
+ log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
594
518
  log("");
595
519
  log(` const payInvoice = guard.protect(payInvoiceRaw,`);
596
- log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }));`);
520
+ log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }),`);
521
+ log(` { mode: "blocking" }); // your real agent typically omits this — durable + a worker`);
597
522
  log("");
598
523
  let decisionId = "";
599
524
  const cfg = {
@@ -615,7 +540,6 @@ async function cmdQuickstart() {
615
540
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
616
541
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
617
542
  log("");
618
- // 3. The human moment — approve right here, or from the Decidio app.
619
543
  if (process.stdin.isTTY) {
620
544
  await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
621
545
  }
@@ -628,7 +552,6 @@ async function cmdQuickstart() {
628
552
  return 1;
629
553
  }
630
554
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
631
- // 4. The blocking wrapper wakes and runs the agent's own function.
632
555
  let result;
633
556
  try {
634
557
  result = await running;
@@ -639,7 +562,6 @@ async function cmdQuickstart() {
639
562
  }
640
563
  log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
641
564
  log("");
642
- // 5. The proof: download the receipt and verify it offline, all in-process.
643
565
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
644
566
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
645
567
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
@@ -649,25 +571,24 @@ async function cmdQuickstart() {
649
571
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
650
572
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
651
573
  const v = verifyReceipt(rec.body.credential, undefined);
652
- log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding verified with NO network, no Decidio account)`);
574
+ log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline; issuer UNPINNED — bind it with: npx @decidio/sdk verify --issuer <your workspace DID>)`);
653
575
  const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
654
576
  const tier = detail.body?.record?.confirmation?.status;
655
577
  if (tier)
656
- log(` ✔ execution evidence on the record: ${tier} — the wrapper's report was cryptographically bound to this agent`);
578
+ log(` ✔ execution evidence on the record: ${tier} — the wrapper's signed report. (The receipt seals the DECISION; execution evidence accrues on the record — see the README.)`);
657
579
  log("");
658
580
  const secs = Math.round((Date.now() - t0) / 1000);
659
- log(`Done in ${secs}s: gated → routed to a human → approved → executed once → sealed, verifiable proof in your hands.`);
581
+ log(`Done in ${secs}s: gated → routed to a human → approved → your function executed once → an authority-decision receipt you can verify offline.`);
660
582
  log("");
661
583
  log("Take it to your real agent — the .env here already works:");
662
584
  log(` import { guard } from "@decidio/sdk";`);
663
585
  log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
586
+ log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
664
587
  log("");
665
- log("Next: the README's 7-step quickstart (durable mode, workers, adapters) · your queue at your Decidio app.");
588
+ log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
666
589
  return v.valid ? 0 : 1;
667
590
  }
668
591
  function cmdDev(target, port) {
669
- // Signal-only relay: receive Decidio's webhook here (a stable URL) and forward it to
670
- // your local agent's resume listener. Useful when the agent isn't directly reachable.
671
592
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
672
593
  createServer((req, res) => {
673
594
  if (req.method !== "POST") {
package/dist/guard.d.ts CHANGED
@@ -8,22 +8,14 @@ export interface GuardFacadeConfig {
8
8
  resumeUrl?: string;
9
9
  webhookSecret?: string;
10
10
  pendingDir?: string;
11
- /** Local signing key (DECIDIO_AGENT_KEY) + public did (DECIDIO_AGENT_DID), from `init`.
12
- * When present the gate authorizes against a PROVEN identity, not a claimed label. */
13
11
  agentKey?: string;
14
12
  agentDid?: string;
15
- /** The workspace this agent acts in (DECIDIO_WORKSPACE_ID). Bound into the signed proof (D-B3.6)
16
- * so a captured proof can't be replayed in another tenant. */
17
13
  workspaceId?: string;
18
- /** Base URL of the Decidio app (DECIDIO_APP_URL) — used to deep-link a routed decision. */
19
14
  appUrl?: string;
20
15
  fetchImpl?: typeof fetch;
21
16
  }
22
17
  export interface ProtectOptions {
23
- /** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest/mcp)
24
- * live in @decidio/sdk/adapters and take the engine handle. */
25
18
  adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
26
- /** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
27
19
  mode?: "durable" | "blocking";
28
20
  }
29
21
  export declare class Guard {
@@ -31,13 +23,8 @@ export declare class Guard {
31
23
  resume?: ResumeController;
32
24
  constructor(config: GuardFacadeConfig, resume?: ResumeController);
33
25
  static fromEnv(): Guard;
34
- /** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
35
26
  resumeHandler(): import("http").RequestListener;
36
- /** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
37
27
  resumeFetchHandler(): (req: Request) => Promise<Response>;
38
- /** Durable resume with NOTHING to host: a background watcher polls Decidio outward and finishes
39
- * any parked approval — so a suspended action survives a process restart. One line at startup.
40
- * Production may swap this for the webhook (resumeUrl); the suspend/resume shape is identical. */
41
28
  worker(opts?: {
42
29
  intervalMs?: number;
43
30
  timeoutMs?: number;
@@ -48,5 +35,4 @@ export declare class Guard {
48
35
  private ensureResume;
49
36
  protect<A extends any[], R>(fn: (...a: A) => Promise<R>, describe: (...a: A) => ApprovalContext, opts?: ProtectOptions): (...a: A) => Promise<R>;
50
37
  }
51
- /** Preconfigured singleton — `import { guard } from "@decidio/sdk"`. */
52
38
  export declare const guard: Guard;