@decidio/sdk 0.1.6 → 0.1.7
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 +42 -9
- package/dist/cli.js +124 -34
- package/dist/guard.d.ts +2 -0
- package/dist/guard.js +5 -1
- package/dist/resume.js +38 -8
- package/openapi.yaml +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,8 @@ Every later command reads `.env` from this directory automatically.
|
|
|
51
51
|
|
|
52
52
|
What `.env` contains after a successful init: `DECIDIO_API_URL`, `DECIDIO_AGENT_ID`,
|
|
53
53
|
`DECIDIO_AGENT_DID` + `DECIDIO_AGENT_KEY` (the local keypair — the key is what earns
|
|
54
|
-
`application_confirmed` evidence later), `DECIDIO_API_TOKEN` (the floor-limited agent token
|
|
54
|
+
`application_confirmed` evidence later), `DECIDIO_API_TOKEN` (the floor-limited agent token —
|
|
55
|
+
90 days by default, `--expires-days N` to change),
|
|
55
56
|
`DECIDIO_WORKSPACE_ID`, and `DECIDIO_WEBHOOK_SECRET` (operator deployments only). Pick any
|
|
56
57
|
agent name — if it's already taken in your workspace, `init` stops cleanly and suggests a
|
|
57
58
|
fresh one (or pass `--unique` to auto-append a random suffix — collision-proof for shared
|
|
@@ -122,8 +123,13 @@ implementation as the standalone `@decidio/verify`). The receipt is a W3C Verifi
|
|
|
122
123
|
Credential: signature and content binding verify with no Decidio account and no network — the
|
|
123
124
|
evidence is yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
|
|
124
125
|
`npx @decidio/sdk verify --issuer <did:key:...> receipt.json` (the DID is shown in your app's
|
|
125
|
-
record verify panel
|
|
126
|
-
you
|
|
126
|
+
record verify panel — or simply read it off the `receipt` command's output, which prints your
|
|
127
|
+
workspace's issuer DID from the authenticated response so you never have to hunt for it).
|
|
128
|
+
|
|
129
|
+
One receipt proves itself. Pass several files and their **ordering** is verified too: the first
|
|
130
|
+
file is the anchor and each subsequent receipt must follow it, so verifying any consecutive
|
|
131
|
+
*segment* works — you are told whether the segment starts at your workspace's genesis or
|
|
132
|
+
mid-history, and a set spanning two different issuers is refused outright.
|
|
127
133
|
|
|
128
134
|
What the credential contains, honestly: the sealed **authority decision** — who decided, what
|
|
129
135
|
was authorized, when, under which policy. It seals at decision time and is immutable — which is
|
|
@@ -168,15 +174,36 @@ waiting: it parks its call arguments agent-side (`.decidio-pending/` by default
|
|
|
168
174
|
stores none of your downstream payload) and throws `DecidioSuspendedError`. The process may
|
|
169
175
|
exit. Two ways the parked action resumes when a human decides:
|
|
170
176
|
|
|
171
|
-
**The worker (self-serve — start here).**
|
|
177
|
+
**The worker (self-serve — start here).** No inbound URL, no shared secrets, and it resumes work
|
|
178
|
+
the current process never started. Here is a complete `agent.mjs` you can run:
|
|
172
179
|
|
|
173
180
|
```js
|
|
174
181
|
import { guard } from "@decidio/sdk";
|
|
175
|
-
|
|
182
|
+
|
|
183
|
+
const payInvoiceRaw = async (invoice) => ({ paid: true, id: invoice.id });
|
|
184
|
+
|
|
185
|
+
// Declare the action name so the wrapper registers at LOAD time, not on first call —
|
|
186
|
+
// that is what lets a restarted process resume actions approved while it was down.
|
|
187
|
+
const payInvoice = guard.protect(
|
|
188
|
+
payInvoiceRaw,
|
|
189
|
+
(invoice) => ({ action: "payInvoice", amount: invoice.amount, scope: "Invoice" }),
|
|
190
|
+
{ action: "payInvoice" },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
guard.worker(); // polls Decidio, re-executes parked actions on approval, one execution each
|
|
194
|
+
|
|
195
|
+
// New work suspends immediately; approvals land later, through the same worker.
|
|
196
|
+
try { await payInvoice({ id: "INV-1", amount: 86_000 }); }
|
|
197
|
+
catch (e) { console.log(e.name); } // DecidioSuspendedError — expected, not a failure
|
|
176
198
|
```
|
|
177
199
|
|
|
178
|
-
|
|
179
|
-
|
|
200
|
+
Run it with `node --env-file=.env agent.mjs`, approve from another terminal, and the running
|
|
201
|
+
worker executes it. Kill the process, restart it, then approve: it still executes, because the
|
|
202
|
+
action was registered at load. **If you can't declare the name at wrap time** (a dynamic
|
|
203
|
+
`describe`), register the raw function explicitly at startup instead —
|
|
204
|
+
`guard.register("payInvoice", payInvoiceRaw)` — before calling `guard.worker()`. Without one of
|
|
205
|
+
those two, a cold worker has an empty registry and reports `no_handler` (the parked action is
|
|
206
|
+
kept, not lost, and the message names the exact fix).
|
|
180
207
|
|
|
181
208
|
**The signed webhook (operator deployments).** Decidio POSTs a signed verdict to your agent's
|
|
182
209
|
`resumeUrl`; the handler verifies the HMAC signature fail-closed, then re-runs your function:
|
|
@@ -198,6 +225,12 @@ your action succeeds but the process dies *before* the entry is cleared, the cla
|
|
|
198
225
|
(robust crash-recovery of stale claims is on the roadmap), so make your downstream action
|
|
199
226
|
idempotent if you need exactly-once under crashes.
|
|
200
227
|
|
|
228
|
+
**Parked arguments must be JSON-serializable.** A suspended action is signed and then written to
|
|
229
|
+
disk as JSON, so values that don't survive `JSON.stringify` round-tripping — `Date`, `Map`,
|
|
230
|
+
`undefined`, `NaN`, class instances with custom `toJSON` — come back changed and fail the
|
|
231
|
+
integrity check on resume (a refusal, never a wrong execution). Pass plain data; rehydrate
|
|
232
|
+
inside your function.
|
|
233
|
+
|
|
201
234
|
**Payload integrity.** When a routed action is parked, the SDK signs its arguments (bound to the
|
|
202
235
|
decision id) with your agent key; on resume it **refuses to execute** if the parked payload was
|
|
203
236
|
modified, its signature stripped, or the file swapped into another decision's slot. That defends
|
|
@@ -227,9 +260,9 @@ imports none of them. Each adapter's shipped `.d.ts` header documents its wiring
|
|
|
227
260
|
`npx @decidio/sdk <cmd>`:
|
|
228
261
|
|
|
229
262
|
- `quickstart` — the guided two-minute tour (gate → approve → execute → proof)
|
|
230
|
-
- `init [agentId] [--unique]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries)
|
|
263
|
+
- `init [agentId] [--unique] [--force] [--expires-days N]` — sign in, register, mint the agent token, write `.env` (+ `.gitignore` entries). Refuses to overwrite a *different* agent's key in this directory; `--force` backs the old `.env` up first.
|
|
231
264
|
- `doctor` — config, connectivity, and what your current token can do
|
|
232
|
-
- `approvals` / `approvals approve|reject <id> [reason]` — the human side, from your terminal
|
|
265
|
+
- `approvals [--mine] [--json]` / `approvals approve|reject <id> [reason]` — the human side, from your terminal (the list is workspace-wide; `--mine` narrows it to this directory's agent)
|
|
233
266
|
- `receipt <id>` — download a sealed decision's Authority Receipt to a `.json` file
|
|
234
267
|
- `verify [--issuer <did>] <file>...` — verify receipts offline (the canonical verifier; the standalone `@decidio/verify` package is the same implementation, for auditors who want zero Decidio dependency)
|
|
235
268
|
- `dev [--target URL]` — local relay for the resume webhook (operator setups)
|
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { generateKeypair } from "./signing.js";
|
|
8
8
|
import { withApproval } from "./index.js";
|
|
9
|
-
import { verifyReceipt
|
|
9
|
+
import { verifyReceipt } from "@decidio/verify";
|
|
10
10
|
const VERSION = (() => {
|
|
11
11
|
try {
|
|
12
12
|
return createRequire(import.meta.url)("../package.json").version;
|
|
@@ -66,14 +66,21 @@ async function api(path, init = {}, bearer) {
|
|
|
66
66
|
}
|
|
67
67
|
return { status: res.status, body };
|
|
68
68
|
}
|
|
69
|
+
function writeSecretFile(path, body) {
|
|
70
|
+
writeFileSync(path, body, { mode: 0o600 });
|
|
71
|
+
try {
|
|
72
|
+
chmodSync(path, 0o600);
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
}
|
|
69
76
|
function upsertEnv(updates) {
|
|
70
77
|
const cur = readEnvFile();
|
|
71
78
|
const merged = { ...cur, ...updates };
|
|
72
79
|
const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
|
|
73
|
-
|
|
80
|
+
writeSecretFile(ENV_PATH, body);
|
|
74
81
|
}
|
|
75
82
|
function ensureGitignore() {
|
|
76
|
-
const entries = [".env", ".env.quickstart", ".decidio-pending/"];
|
|
83
|
+
const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
|
|
77
84
|
const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
78
85
|
const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
|
|
79
86
|
if (!missing.length)
|
|
@@ -95,13 +102,13 @@ function ask(question, mute = false) {
|
|
|
95
102
|
});
|
|
96
103
|
}
|
|
97
104
|
async function acquireSession(purpose) {
|
|
98
|
-
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
99
|
-
return process.env.DECIDIO_SESSION_TOKEN;
|
|
100
105
|
if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
|
|
101
|
-
log(`✘ refusing to send
|
|
106
|
+
log(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
|
|
102
107
|
log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
|
|
103
108
|
return null;
|
|
104
109
|
}
|
|
110
|
+
if (process.env.DECIDIO_SESSION_TOKEN)
|
|
111
|
+
return process.env.DECIDIO_SESSION_TOKEN;
|
|
105
112
|
if (!process.stdin.isTTY) {
|
|
106
113
|
log(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
|
|
107
114
|
log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
|
|
@@ -138,10 +145,20 @@ async function acquireSession(purpose) {
|
|
|
138
145
|
log(` Sign-in links are single-use and expire in 15 minutes — request a fresh one and re-run.`);
|
|
139
146
|
return null;
|
|
140
147
|
}
|
|
141
|
-
async function cmdInit(agentId) {
|
|
148
|
+
async function cmdInit(agentId, opts = {}) {
|
|
142
149
|
log(`Registering agent "${agentId}" with Decidio at ${API}…`);
|
|
143
|
-
const
|
|
144
|
-
const
|
|
150
|
+
const existingEnv = readEnvFile();
|
|
151
|
+
const existingKey = existingEnv.DECIDIO_AGENT_KEY;
|
|
152
|
+
const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
|
|
153
|
+
if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
|
|
154
|
+
log(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
|
|
155
|
+
log(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
|
|
156
|
+
log(` overwritten is gone for good (it never left this machine). Nothing was written.`);
|
|
157
|
+
log(` Pick one:`);
|
|
158
|
+
log(` 1. New directory (cleanest): mkdir ${agentId} && cd ${agentId} && npx @decidio/sdk init ${agentId}`);
|
|
159
|
+
log(` 2. Replace the old agent here, backing its .env up first: npx @decidio/sdk init ${agentId} --force`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
145
162
|
const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
|
|
146
163
|
const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
|
|
147
164
|
let session = null;
|
|
@@ -183,11 +200,12 @@ async function cmdInit(agentId) {
|
|
|
183
200
|
let agentToken, workspaceId;
|
|
184
201
|
const mintBearer = session ?? TOKEN();
|
|
185
202
|
if (mintBearer) {
|
|
186
|
-
const
|
|
203
|
+
const expiresInDays = opts.expiresDays ?? 90;
|
|
204
|
+
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
|
|
187
205
|
if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
|
|
188
206
|
agentToken = mint.body.token;
|
|
189
207
|
workspaceId = mint.body.workspaceId;
|
|
190
|
-
log(`✔ minted the agent's API token — floor-limited: it can request
|
|
208
|
+
log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
|
|
191
209
|
}
|
|
192
210
|
else if (session) {
|
|
193
211
|
log(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
@@ -210,11 +228,16 @@ async function cmdInit(agentId) {
|
|
|
210
228
|
envOut.DECIDIO_API_TOKEN = agentToken;
|
|
211
229
|
if (workspaceId)
|
|
212
230
|
envOut.DECIDIO_WORKSPACE_ID = workspaceId;
|
|
231
|
+
ensureGitignore();
|
|
232
|
+
if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
|
|
233
|
+
const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
|
|
234
|
+
writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
|
|
235
|
+
log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
|
|
236
|
+
}
|
|
213
237
|
upsertEnv(envOut);
|
|
214
238
|
for (const [k, v] of Object.entries(envOut))
|
|
215
239
|
process.env[k] = v;
|
|
216
|
-
log(`✔ wrote ${ENV_PATH} — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
|
|
217
|
-
ensureGitignore();
|
|
240
|
+
log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
|
|
218
241
|
if (privateKey)
|
|
219
242
|
log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
|
|
220
243
|
log("");
|
|
@@ -258,6 +281,22 @@ async function cmdDoctor() {
|
|
|
258
281
|
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.");
|
|
259
282
|
const h = await api("/api/health", {}, "");
|
|
260
283
|
line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
|
|
284
|
+
const tok = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
|
|
285
|
+
if (tok) {
|
|
286
|
+
try {
|
|
287
|
+
const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
288
|
+
if (typeof payload?.exp === "number") {
|
|
289
|
+
const days = Math.floor((payload.exp * 1000 - Date.now()) / 86_400_000);
|
|
290
|
+
if (days < 0)
|
|
291
|
+
line(false, `agent token: EXPIRED ${-days} day(s) ago — re-run \`npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}\` to mint a fresh one.`);
|
|
292
|
+
else if (days <= 14)
|
|
293
|
+
log(`⚠ agent token: expires in ${days} day(s) — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
|
|
294
|
+
else
|
|
295
|
+
log(`✔ agent token: valid for another ${days} days`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch { }
|
|
299
|
+
}
|
|
261
300
|
const rec = await api("/api/records");
|
|
262
301
|
if (rec.status >= 200 && rec.status < 300)
|
|
263
302
|
log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
|
|
@@ -275,9 +314,10 @@ async function cmdDoctor() {
|
|
|
275
314
|
log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
|
|
276
315
|
return ok ? 0 : 1;
|
|
277
316
|
}
|
|
278
|
-
async function cmdApprovals(sub, id, reason) {
|
|
279
|
-
const jsonMode =
|
|
280
|
-
|
|
317
|
+
async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
318
|
+
const jsonMode = !!flags.json;
|
|
319
|
+
const mineOnly = !!flags.mine;
|
|
320
|
+
if (sub?.startsWith("--"))
|
|
281
321
|
sub = undefined;
|
|
282
322
|
let bearer;
|
|
283
323
|
const probe = await api("/api/decisions/cards");
|
|
@@ -331,11 +371,16 @@ async function cmdApprovals(sub, id, reason) {
|
|
|
331
371
|
return 0;
|
|
332
372
|
}
|
|
333
373
|
const myAgent = process.env.DECIDIO_AGENT_ID;
|
|
334
|
-
|
|
335
|
-
|
|
374
|
+
const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
|
|
375
|
+
const shown = mineOnly ? pending.filter(isMine) : pending;
|
|
376
|
+
if (mineOnly && !shown.length) {
|
|
377
|
+
log(`No pending approvals for "${myAgent}" (${pending.length} pending elsewhere in this workspace — drop --mine to see them).`);
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
log(`Pending agent approvals (${shown.length}${mineOnly ? ` for "${myAgent}"` : ""})${!mineOnly && myAgent ? ` — workspace-wide; "←" marks this directory's agent (${myAgent}). Use --mine to list only those.` : ""}:`);
|
|
381
|
+
for (const c of shown) {
|
|
336
382
|
const ar = c.agentRequest ?? {};
|
|
337
|
-
|
|
338
|
-
log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}${mine ? " ←" : ""}`);
|
|
383
|
+
log(` ${c.id} ${ar.action ?? "?"}${ar.amount != null ? ` $${Number(ar.amount).toLocaleString()}` : ""} — ${c.title ?? ""}${!mineOnly && isMine(c) ? " ←" : ""}`);
|
|
339
384
|
}
|
|
340
385
|
log(`\nApprove: npx @decidio/sdk approvals approve <id> [reason]`);
|
|
341
386
|
log(`Reject: npx @decidio/sdk approvals reject <id> [reason]`);
|
|
@@ -381,16 +426,24 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
381
426
|
}
|
|
382
427
|
const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
|
|
383
428
|
const conf = detail.body?.record?.confirmation;
|
|
429
|
+
const issuer = r.body?.issuer;
|
|
384
430
|
if (jsonMode) {
|
|
385
|
-
log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed }, null, 2));
|
|
431
|
+
log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
|
|
386
432
|
return 0;
|
|
387
433
|
}
|
|
388
434
|
if (conf?.status)
|
|
389
435
|
log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
|
|
390
436
|
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
391
437
|
log(` npx @decidio/sdk verify ${name}`);
|
|
392
|
-
|
|
393
|
-
|
|
438
|
+
if (issuer) {
|
|
439
|
+
log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
|
|
440
|
+
log(` ${issuer}`);
|
|
441
|
+
log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
|
|
445
|
+
log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
|
|
446
|
+
}
|
|
394
447
|
return 0;
|
|
395
448
|
}
|
|
396
449
|
function parseReceiptStrict(text, file) {
|
|
@@ -440,6 +493,27 @@ function parseReceiptStrict(text, file) {
|
|
|
440
493
|
}
|
|
441
494
|
return value;
|
|
442
495
|
}
|
|
496
|
+
function chainOf(vcs, expectedIssuer) {
|
|
497
|
+
if (!vcs.length)
|
|
498
|
+
return { valid: false, length: 0, reason: "no receipts supplied" };
|
|
499
|
+
const issuers = [...new Set(vcs.map((v) => String(v?.issuer ?? "")))];
|
|
500
|
+
if (issuers.length > 1)
|
|
501
|
+
return { valid: false, length: 0, reason: `these receipts come from ${issuers.length} DIFFERENT issuers — a chain must be issued end to end by one issuer` };
|
|
502
|
+
let prev = null;
|
|
503
|
+
let anchor = "segment";
|
|
504
|
+
for (let i = 0; i < vcs.length; i++) {
|
|
505
|
+
const r = verifyReceipt(vcs[i], expectedIssuer);
|
|
506
|
+
if (!r.valid)
|
|
507
|
+
return { valid: false, length: i, reason: `receipt ${i} (${r.recordId ?? "?"}) invalid: ${r.reason}` };
|
|
508
|
+
const cs = vcs[i]?.credentialSubject ?? {};
|
|
509
|
+
if (i === 0)
|
|
510
|
+
anchor = (cs.prevHash ?? null) === null ? "genesis" : "segment";
|
|
511
|
+
else if ((cs.prevHash ?? null) !== prev)
|
|
512
|
+
return { valid: false, length: i, reason: `receipt ${i} does not follow receipt ${i - 1} (prevHash mismatch)` };
|
|
513
|
+
prev = cs.contentHash;
|
|
514
|
+
}
|
|
515
|
+
return { valid: true, length: vcs.length, anchor };
|
|
516
|
+
}
|
|
443
517
|
function cmdVerify(argsIn) {
|
|
444
518
|
const issIdx = argsIn.indexOf("--issuer");
|
|
445
519
|
if (issIdx >= 0 && (argsIn[issIdx + 1] === undefined || argsIn[issIdx + 1].startsWith("--"))) {
|
|
@@ -470,9 +544,9 @@ function cmdVerify(argsIn) {
|
|
|
470
544
|
log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
|
|
471
545
|
});
|
|
472
546
|
if (vcs.length > 1) {
|
|
473
|
-
const c =
|
|
547
|
+
const c = chainOf(vcs, expectedIssuer);
|
|
474
548
|
allOk = allOk && c.valid;
|
|
475
|
-
log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.reason ? ` — ${c.reason}` : ""}`);
|
|
549
|
+
log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.valid ? (c.anchor === "segment" ? " — segment: ordering proven from the FIRST file forward; history before it is not covered by these receipts" : " — from genesis") : ""}${c.reason ? ` — ${c.reason}` : ""}`);
|
|
476
550
|
}
|
|
477
551
|
return allOk ? 0 : 1;
|
|
478
552
|
}
|
|
@@ -492,7 +566,7 @@ async function cmdQuickstart() {
|
|
|
492
566
|
log(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
|
|
493
567
|
return 1;
|
|
494
568
|
}
|
|
495
|
-
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart" }) }, session);
|
|
569
|
+
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
|
|
496
570
|
if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
|
|
497
571
|
log(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
498
572
|
return 1;
|
|
@@ -570,8 +644,11 @@ async function cmdQuickstart() {
|
|
|
570
644
|
}
|
|
571
645
|
const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
572
646
|
writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
573
|
-
const
|
|
574
|
-
|
|
647
|
+
const issuer = rec.body?.issuer;
|
|
648
|
+
const v = verifyReceipt(rec.body.credential, issuer);
|
|
649
|
+
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
|
|
650
|
+
if (issuer)
|
|
651
|
+
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
575
652
|
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
576
653
|
const tier = detail.body?.record?.confirmation?.status;
|
|
577
654
|
if (tier)
|
|
@@ -625,9 +702,12 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
625
702
|
|
|
626
703
|
npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
|
|
627
704
|
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
628
|
-
|
|
705
|
+
--unique append a random suffix (collision-proof samples)
|
|
706
|
+
--force replace a different agent already in this .env
|
|
707
|
+
(its .env is backed up first)
|
|
708
|
+
--expires-days N agent-token lifetime (default 90)
|
|
629
709
|
npx @decidio/sdk doctor check config, connectivity, and what your token can do
|
|
630
|
-
npx @decidio/sdk approvals [--json]
|
|
710
|
+
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
631
711
|
npx @decidio/sdk approvals approve <id> [reason]
|
|
632
712
|
npx @decidio/sdk approvals reject <id> [reason]
|
|
633
713
|
npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
|
|
@@ -657,14 +737,24 @@ async function main(argv) {
|
|
|
657
737
|
case "init": {
|
|
658
738
|
const argsIn = argv.slice(3);
|
|
659
739
|
const unique = argsIn.includes("--unique");
|
|
660
|
-
const
|
|
740
|
+
const expIdx = argsIn.indexOf("--expires-days");
|
|
741
|
+
const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : undefined;
|
|
742
|
+
if (expIdx >= 0 && (!expRaw || !/^\d+$/.test(expRaw) || Number(expRaw) < 1)) {
|
|
743
|
+
log("✘ --expires-days needs a positive whole number of days (e.g. --expires-days 30).");
|
|
744
|
+
return 2;
|
|
745
|
+
}
|
|
746
|
+
const nameArg = argsIn.find((x, i) => !x.startsWith("-") && !(expIdx >= 0 && i === expIdx + 1));
|
|
661
747
|
let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
|
|
662
748
|
if (unique)
|
|
663
749
|
name = `${name}-${randomBytes(2).toString("hex")}`;
|
|
664
|
-
return cmdInit(name);
|
|
750
|
+
return cmdInit(name, { force: argsIn.includes("--force"), expiresDays: expRaw ? Number(expRaw) : undefined });
|
|
665
751
|
}
|
|
666
752
|
case "doctor": return cmdDoctor();
|
|
667
|
-
case "approvals":
|
|
753
|
+
case "approvals": {
|
|
754
|
+
const argsIn = argv.slice(3);
|
|
755
|
+
const positional = argsIn.filter((x) => !x.startsWith("-"));
|
|
756
|
+
return cmdApprovals(positional[0], positional[1], positional.slice(2).join(" ") || undefined, { json: argsIn.includes("--json"), mine: argsIn.includes("--mine") });
|
|
757
|
+
}
|
|
668
758
|
case "receipt": {
|
|
669
759
|
const argsIn = argv.slice(3);
|
|
670
760
|
return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
|
package/dist/guard.d.ts
CHANGED
|
@@ -17,12 +17,14 @@ export interface GuardFacadeConfig {
|
|
|
17
17
|
export interface ProtectOptions {
|
|
18
18
|
adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest" | "mcp";
|
|
19
19
|
mode?: "durable" | "blocking";
|
|
20
|
+
action?: string;
|
|
20
21
|
}
|
|
21
22
|
export declare class Guard {
|
|
22
23
|
config: GuardFacadeConfig;
|
|
23
24
|
resume?: ResumeController;
|
|
24
25
|
constructor(config: GuardFacadeConfig, resume?: ResumeController);
|
|
25
26
|
static fromEnv(): Guard;
|
|
27
|
+
register(action: string, fn: (...a: any[]) => Promise<any>): void;
|
|
26
28
|
resumeHandler(): import("http").RequestListener;
|
|
27
29
|
resumeFetchHandler(): (req: Request) => Promise<Response>;
|
|
28
30
|
worker(opts?: {
|
package/dist/guard.js
CHANGED
|
@@ -22,6 +22,7 @@ export class Guard {
|
|
|
22
22
|
appUrl: process.env.DECIDIO_APP_URL,
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
|
+
register(action, fn) { this.ensureResume().register(action, fn); }
|
|
25
26
|
resumeHandler() { return this.ensureResume().webhookHandler(); }
|
|
26
27
|
resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
|
|
27
28
|
worker(opts) {
|
|
@@ -53,7 +54,10 @@ export class Guard {
|
|
|
53
54
|
"Omit mode:\"blocking\" for durable async resume on real (minutes-to-days) approvals.");
|
|
54
55
|
return withApproval(fn, describe, base);
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
+
const resume = this.ensureResume();
|
|
58
|
+
if (opts?.action)
|
|
59
|
+
resume.register(opts.action, fn);
|
|
60
|
+
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume });
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
export const guard = Guard.fromEnv();
|
package/dist/resume.js
CHANGED
|
@@ -57,7 +57,7 @@ export class FilePendingStore {
|
|
|
57
57
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
58
58
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
59
59
|
claimedPath(id) { return join(this.dir, `${this.key(id)}.claimed`); }
|
|
60
|
-
async put(p) { writeFileSync(this.path(p.decisionId), JSON.stringify(p, null, 2)); }
|
|
60
|
+
async put(p) { writeFileSync(this.path(p.decisionId), JSON.stringify(p, null, 2), { mode: 0o600 }); }
|
|
61
61
|
async get(id) {
|
|
62
62
|
for (const f of [this.path(id), this.claimedPath(id)])
|
|
63
63
|
if (existsSync(f))
|
|
@@ -70,8 +70,16 @@ export class FilePendingStore {
|
|
|
70
70
|
async list() {
|
|
71
71
|
if (!existsSync(this.dir))
|
|
72
72
|
return [];
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
|
|
75
|
+
try {
|
|
76
|
+
out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
75
83
|
}
|
|
76
84
|
async claim(id) {
|
|
77
85
|
try {
|
|
@@ -143,7 +151,12 @@ export function createDecidioResume(opts) {
|
|
|
143
151
|
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
144
152
|
webhookSecret: secret,
|
|
145
153
|
store: opts.store,
|
|
146
|
-
register(action, fn) {
|
|
154
|
+
register(action, fn) {
|
|
155
|
+
const prev = registry.get(action);
|
|
156
|
+
if (prev && prev !== fn)
|
|
157
|
+
console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT function — the latest one wins, and a resumed decision would run IT. Give each protected function its own action name.`);
|
|
158
|
+
registry.set(action, fn);
|
|
159
|
+
},
|
|
147
160
|
async park(p) {
|
|
148
161
|
const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
|
|
149
162
|
if (!argsSig)
|
|
@@ -187,19 +200,36 @@ export function createDecidioResume(opts) {
|
|
|
187
200
|
if (integrity === "unbound")
|
|
188
201
|
console.warn(`[decidio] executing ${decisionId} from an UNBOUND parked entry (this agent has no key material to bind or verify payloads) — its arguments cannot be integrity-checked. Run \`npx @decidio/sdk init\` to enable binding.`);
|
|
189
202
|
const fn = registry.get(pending.action);
|
|
190
|
-
if (!fn)
|
|
191
|
-
|
|
203
|
+
if (!fn) {
|
|
204
|
+
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
205
|
+
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
206
|
+
console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
|
|
207
|
+
console.error(` or register the raw function directly:`);
|
|
208
|
+
console.error(` guard.register("${pending.action}", doItRaw);`);
|
|
209
|
+
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
210
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" in this process — declare { action } on protect(), or call guard.register("${pending.action}", fn) at startup` });
|
|
211
|
+
}
|
|
192
212
|
const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
|
|
193
213
|
if (!claimed)
|
|
194
214
|
return { status: "unknown", decisionId };
|
|
195
215
|
try {
|
|
196
216
|
const result = await fn(...pending.args);
|
|
197
|
-
|
|
217
|
+
let confirmed = true;
|
|
198
218
|
try {
|
|
199
219
|
await confirm(ctrl, decisionId, result);
|
|
200
220
|
}
|
|
201
221
|
catch (e) {
|
|
202
|
-
|
|
222
|
+
confirmed = false;
|
|
223
|
+
console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
|
|
224
|
+
}
|
|
225
|
+
const retainedIsSafe = typeof ctrl.store.claim === "function";
|
|
226
|
+
if (confirmed || !retainedIsSafe)
|
|
227
|
+
await ctrl.store.delete(decisionId);
|
|
228
|
+
if (!confirmed) {
|
|
229
|
+
if (retainedIsSafe)
|
|
230
|
+
console.warn(` The parked entry for ${decisionId} is kept as local evidence that it executed — it stays CLAIMED, so it cannot re-run. Retry the confirmation, then delete it.`);
|
|
231
|
+
else
|
|
232
|
+
console.warn(` This store provides no claim() primitive, so the entry was DELETED to guarantee the action cannot execute twice. ${decisionId} executed, but its confirmation never reached Decidio — re-confirm it from your own records.`);
|
|
203
233
|
}
|
|
204
234
|
return finish({ status: "executed", decisionId, action: pending.action, result });
|
|
205
235
|
}
|
package/openapi.yaml
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
openapi: 3.1.0
|
|
6
6
|
info:
|
|
7
7
|
title: Decidio Agent Gate
|
|
8
|
-
version: 0.1.
|
|
8
|
+
version: 0.1.7
|
|
9
9
|
description: >
|
|
10
10
|
Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
|
|
11
11
|
receive the signed resume webhook when a routed action is decided. Every outcome seals a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|