@decidio/sdk 0.1.2 → 0.1.4
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 +56 -15
- package/dist/adapters/_gate.js +3 -0
- package/dist/cli.js +280 -23
- package/dist/index.d.ts +10 -0
- package/dist/index.js +34 -2
- package/dist/resume.js +4 -0
- package/openapi.yaml +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -2,12 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
One line to put a human-or-policy approval gate in front of any AI-agent action — and get a signed, verifiable Authority Receipt for every outcome.
|
|
4
4
|
|
|
5
|
+
## The two-minute tour
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @decidio/sdk quickstart
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
One command, guided: it registers a fresh agent (key generated locally), gates a demo action
|
|
12
|
+
with the same one line you'll use on your real function, routes it to a human — **you** — lets
|
|
13
|
+
you approve right there (or from your Decidio app), executes on approval, then downloads the
|
|
14
|
+
sealed Authority Receipt and verifies it offline in front of you. Sign in when prompted, or
|
|
15
|
+
export `DECIDIO_SESSION_TOKEN` for headless terminals. No config: with no `DECIDIO_API_URL` set
|
|
16
|
+
it targets the hosted synthetic sandbox.
|
|
17
|
+
|
|
5
18
|
## Quickstart — seven steps to your first sealed receipt
|
|
6
19
|
|
|
7
20
|
Under 30 minutes, no help needed. You'll need Node 20.6+, a terminal, and a Decidio workspace
|
|
8
21
|
(request one at [decidioai.com](https://decidioai.com/#early-access) — the hosted sandbox is
|
|
9
|
-
synthetic data, safe to experiment in).
|
|
10
|
-
|
|
22
|
+
synthetic data, safe to experiment in).
|
|
23
|
+
|
|
24
|
+
**PowerShell users** — the equivalents for every Bash idiom used below:
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
mkdir decidio-quickstart; cd decidio-quickstart # instead of mkdir ... && cd ...
|
|
28
|
+
$env:DECIDIO_API_URL = "https://decidio-api.onrender.com" # instead of export NAME=value
|
|
29
|
+
$env:DECIDIO_SOURCE_SYSTEM = "salesforce"; npx @decidio/sdk init my-sf-agent
|
|
30
|
+
# instead of VAR=value npx ... (inline env)
|
|
31
|
+
```
|
|
11
32
|
|
|
12
33
|
**1. Install**
|
|
13
34
|
|
|
@@ -29,6 +50,17 @@ is sent), registers the agent, mints its **floor-limited API token** (it can req
|
|
|
29
50
|
and nothing else — your workspace session never touches the agent's disk), and writes `.env`.
|
|
30
51
|
Every later command reads `.env` from this directory automatically.
|
|
31
52
|
|
|
53
|
+
What `.env` contains after a successful init: `DECIDIO_API_URL`, `DECIDIO_AGENT_ID`,
|
|
54
|
+
`DECIDIO_AGENT_DID` + `DECIDIO_AGENT_KEY` (the local keypair — the key is what earns
|
|
55
|
+
`application_confirmed` evidence later), `DECIDIO_API_TOKEN` (the floor-limited agent token),
|
|
56
|
+
`DECIDIO_WORKSPACE_ID`, and `DECIDIO_WEBHOOK_SECRET` (operator deployments only). Pick any
|
|
57
|
+
agent name — if it's already taken in your workspace, `init` stops cleanly and suggests a
|
|
58
|
+
fresh one (or pass `--unique` to auto-append a random suffix — collision-proof for shared
|
|
59
|
+
workspaces and CI). Headless terminal (no prompts possible)? Export `DECIDIO_SESSION_TOKEN` first:
|
|
60
|
+
request a sign-in link, then exchange its `?login=` value at `POST /demo/login-consume` — the
|
|
61
|
+
response's `token` is your session token (the CLI prints these exact instructions when it
|
|
62
|
+
can't prompt).
|
|
63
|
+
|
|
32
64
|
**3. Protect one function** — save this as `quickstart.mjs`:
|
|
33
65
|
|
|
34
66
|
```js
|
|
@@ -62,7 +94,11 @@ changed: `DECIDIO_SOURCE_SYSTEM=salesforce npx @decidio/sdk init my-sf-agent`. T
|
|
|
62
94
|
named rule auto-approves amounts under $1M when the request carries the agent's signed
|
|
63
95
|
identity proof, so even this $86k example would proceed there.)
|
|
64
96
|
|
|
65
|
-
**5. Approve it** — in a second terminal (same directory
|
|
97
|
+
**5. Approve it** — in a second terminal (same directory; replace `<decisionId>` with the id
|
|
98
|
+
step 4 printed — angle brackets are placeholders, never typed literally). Approvals are a
|
|
99
|
+
*human* act, so this terminal needs your sign-in too: it will prompt, or export
|
|
100
|
+
`DECIDIO_SESSION_TOKEN` here as well for headless shells (the `.env` deliberately holds only
|
|
101
|
+
the agent's floor-limited token — it cannot approve anything):
|
|
66
102
|
|
|
67
103
|
```bash
|
|
68
104
|
npx @decidio/sdk approvals # see it pending
|
|
@@ -73,27 +109,32 @@ The moment you approve, the first terminal wakes: **your own function executes**
|
|
|
73
109
|
holds your credentials or runs your code — and the SDK reports the captured result back, which
|
|
74
110
|
becomes the record's execution evidence.
|
|
75
111
|
|
|
76
|
-
**6. Download the receipt
|
|
112
|
+
**6. Download the receipt (online), then verify it (offline):**
|
|
77
113
|
|
|
78
114
|
```bash
|
|
79
115
|
npx @decidio/sdk receipt <decisionId>
|
|
80
|
-
npx @decidio/verify decidio-receipt-<decisionId>.json
|
|
116
|
+
npx @decidio/sdk verify decidio-receipt-<decisionId>.json
|
|
81
117
|
```
|
|
82
118
|
|
|
83
|
-
The
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
The download is an authenticated fetch from your workspace; the *verification* is fully
|
|
120
|
+
offline — the verifier ships inside this package (no second download, no registry probe, same
|
|
121
|
+
implementation as the standalone `@decidio/verify`). The receipt is a W3C Verifiable
|
|
122
|
+
Credential: signature and content binding verify with no Decidio account and no network — the
|
|
123
|
+
evidence is yours, not ours. To also prove *who* issued it, pin your workspace's issuer:
|
|
124
|
+
`npx @decidio/sdk verify --issuer <did:key:...> receipt.json` (the DID is shown in your app's
|
|
87
125
|
record verify panel). One receipt proves itself; chain order across receipts is verified when
|
|
88
126
|
you pass several files at once.
|
|
89
127
|
|
|
90
128
|
What the credential contains, honestly: the sealed **authority decision** — who decided, what
|
|
91
|
-
was authorized, when, under which policy. It seals at decision time and is immutable
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
129
|
+
was authorized, when, under which policy. It seals at decision time and is immutable — which is
|
|
130
|
+
why the credential's own `confirmation.status` field reads `"pending"` forever. Execution
|
|
131
|
+
evidence that arrives *after* the seal (your function's captured result) lives on the
|
|
132
|
+
**record**, not inside the credential bytes: the `receipt` command prints the record's current
|
|
133
|
+
tier next to the file, and with the signing key `init` wrote you'll see `application_confirmed`
|
|
134
|
+
— the wrapper's report was cryptographically bound to your agent. (No signing key configured?
|
|
135
|
+
The server caps the evidence at the weaker `agent_asserted`, and the SDK warns at confirm
|
|
136
|
+
time.) Immutable seal + accruing evidence is the design, not an omission — corrections and
|
|
137
|
+
later facts supersede, they never rewrite.
|
|
97
138
|
|
|
98
139
|
**7. Prove a replay is rejected:**
|
|
99
140
|
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -32,6 +32,9 @@ export async function gateConfirm(config, decisionId, result) {
|
|
|
32
32
|
// Sign the confirm too (bound to {action:"confirm", scope:decisionId}) so the adapter path
|
|
33
33
|
// earns application_confirmed; without a key the server caps it at agent_asserted.
|
|
34
34
|
const requesterIdentity = signConfirm(decisionId, config);
|
|
35
|
+
// Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch).
|
|
36
|
+
if (!requesterIdentity)
|
|
37
|
+
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.`);
|
|
35
38
|
const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
36
39
|
method: "POST",
|
|
37
40
|
headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,8 @@ import { createServer } from "node:http";
|
|
|
24
24
|
import { createInterface } from "node:readline";
|
|
25
25
|
import { createRequire } from "node:module";
|
|
26
26
|
import { generateKeypair } from "./signing.js";
|
|
27
|
+
import { withApproval } from "./index.js";
|
|
28
|
+
import { verifyReceipt, verifyChain } from "@decidio/verify";
|
|
27
29
|
const VERSION = (() => {
|
|
28
30
|
try {
|
|
29
31
|
return createRequire(import.meta.url)("../package.json").version;
|
|
@@ -55,13 +57,19 @@ for (const [k, v] of Object.entries(readEnvFile())) {
|
|
|
55
57
|
if (process.env[k] === undefined)
|
|
56
58
|
process.env[k] = v;
|
|
57
59
|
}
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
const IS_QUICKSTART = process.argv[2] === "quickstart";
|
|
63
|
+
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
|
+
const TOKEN = () => process.env.DECIDIO_API_TOKEN;
|
|
60
68
|
const ENV_PATH = ".env";
|
|
61
69
|
const log = (...a) => console.log(...a);
|
|
62
70
|
/** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
|
|
63
71
|
async function api(path, init = {}, bearer) {
|
|
64
|
-
const auth = bearer ?? TOKEN;
|
|
72
|
+
const auth = bearer ?? TOKEN();
|
|
65
73
|
let res;
|
|
66
74
|
try {
|
|
67
75
|
res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
|
|
@@ -189,18 +197,24 @@ async function cmdInit(agentId) {
|
|
|
189
197
|
}
|
|
190
198
|
let did, privateKey;
|
|
191
199
|
if (r.status === 409) {
|
|
192
|
-
// Already registered to a
|
|
193
|
-
//
|
|
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).
|
|
194
204
|
did = r.body?.did;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
205
|
+
if (existingKey) {
|
|
206
|
+
log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it with the DECIDIO_AGENT_KEY already in .env.`);
|
|
207
|
+
if (sourceSystem)
|
|
208
|
+
log(` Note: sourceSystem can't change on an existing agent — the registered lane stands. To use the "${sourceSystem}" lane, register a NEW name.`);
|
|
209
|
+
}
|
|
200
210
|
else {
|
|
201
|
-
|
|
202
|
-
log(
|
|
203
|
-
log(`
|
|
211
|
+
const suggestion = `${agentId}-${randomBytes(2).toString("hex")}`;
|
|
212
|
+
log(`✘ "${agentId}" is already registered in this workspace, and its private key is not on this machine`);
|
|
213
|
+
log(` (keys never leave the machine that generated them — likely a previous run, or another directory).`);
|
|
214
|
+
log(` Nothing was written. Two ways forward:`);
|
|
215
|
+
log(` 1. Fresh agent (most common): npx @decidio/sdk init ${suggestion}`);
|
|
216
|
+
log(` 2. Reuse "${agentId}": restore its original DECIDIO_AGENT_KEY into .env here, then re-run init.`);
|
|
217
|
+
return 1;
|
|
204
218
|
}
|
|
205
219
|
}
|
|
206
220
|
else if (r.status >= 200 && r.status < 300) {
|
|
@@ -218,7 +232,7 @@ async function cmdInit(agentId) {
|
|
|
218
232
|
// without the tester-lab fence (plain local dev) the mint endpoint refuses; that's fine —
|
|
219
233
|
// local dev auths with its existing env and we say so instead of failing the init.
|
|
220
234
|
let agentToken, workspaceId;
|
|
221
|
-
const mintBearer = session ?? TOKEN;
|
|
235
|
+
const mintBearer = session ?? TOKEN();
|
|
222
236
|
if (mintBearer) {
|
|
223
237
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli" }) }, mintBearer);
|
|
224
238
|
if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
|
|
@@ -248,6 +262,11 @@ async function cmdInit(agentId) {
|
|
|
248
262
|
if (workspaceId)
|
|
249
263
|
envOut.DECIDIO_WORKSPACE_ID = workspaceId;
|
|
250
264
|
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
|
+
for (const [k, v] of Object.entries(envOut))
|
|
269
|
+
process.env[k] = v;
|
|
251
270
|
log(`✔ wrote ${ENV_PATH} — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
|
|
252
271
|
ensureGitignore();
|
|
253
272
|
if (privateKey)
|
|
@@ -274,7 +293,7 @@ async function cmdInit(agentId) {
|
|
|
274
293
|
log("");
|
|
275
294
|
log(` node --env-file=.env quickstart.mjs # routes to a human, prints the decision id, waits`);
|
|
276
295
|
log(` npx @decidio/sdk approvals # (second terminal) see it pending`);
|
|
277
|
-
log(` npx @decidio/sdk approvals approve <id> # approve -> your function runs ->
|
|
296
|
+
log(` npx @decidio/sdk approvals approve <id> # approve -> your function runs -> evidence recorded`);
|
|
278
297
|
log("");
|
|
279
298
|
return cmdDoctor();
|
|
280
299
|
}
|
|
@@ -305,12 +324,13 @@ async function cmdDoctor() {
|
|
|
305
324
|
else if (rec.status === 401) {
|
|
306
325
|
// A 401 off the agent floor could also mean an INVALID token (Codex catch: don't
|
|
307
326
|
// false-green expiry). Disambiguate with a floor probe: a valid agent token gets 404 for an
|
|
308
|
-
// unknown decision (authorized, not found); an invalid one gets 401 again.
|
|
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.
|
|
309
329
|
const probe = await api("/agent/status/doctor-scope-probe");
|
|
310
330
|
if (probe.status === 404)
|
|
311
331
|
log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
|
|
312
332
|
else if (probe.status === 401)
|
|
313
|
-
|
|
333
|
+
line(false, "token: REJECTED on the agent floor too — it's invalid or expired. Re-run `npx @decidio/sdk init` to mint a fresh one.");
|
|
314
334
|
else
|
|
315
335
|
log(`⚠ token scope: agent-floor probe returned ${probe.status} — inconclusive`);
|
|
316
336
|
}
|
|
@@ -322,6 +342,9 @@ async function cmdDoctor() {
|
|
|
322
342
|
async function cmdApprovals(sub, id, reason) {
|
|
323
343
|
// Approvals are a HUMAN act on the workspace — session scope, never the agent token. If the
|
|
324
344
|
// env's token is agent-floor (401 on /api/*), sign in interactively; memory-only as always.
|
|
345
|
+
const jsonMode = sub === "--json";
|
|
346
|
+
if (jsonMode)
|
|
347
|
+
sub = undefined;
|
|
325
348
|
let bearer;
|
|
326
349
|
const probe = await api("/api/decisions/cards");
|
|
327
350
|
if (probe.status === 401 || probe.status === 403) {
|
|
@@ -368,6 +391,11 @@ async function cmdApprovals(sub, id, reason) {
|
|
|
368
391
|
}
|
|
369
392
|
const cards = r.body?.cards ?? [];
|
|
370
393
|
const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
|
|
394
|
+
if (jsonMode) {
|
|
395
|
+
// Stable machine output (round-3 ask): agents scripting the queue parse this, not prose.
|
|
396
|
+
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
|
+
return 0;
|
|
398
|
+
}
|
|
371
399
|
if (!pending.length) {
|
|
372
400
|
log("No pending agent approvals. Trigger one from your protected function, then re-run this.");
|
|
373
401
|
return 0;
|
|
@@ -410,7 +438,7 @@ async function cmdReceipt(id) {
|
|
|
410
438
|
}
|
|
411
439
|
const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
412
440
|
writeFileSync(name, JSON.stringify(r.body.credential, null, 2) + "\n");
|
|
413
|
-
log(`✔ wrote ${name}`);
|
|
441
|
+
log(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
|
|
414
442
|
// The credential seals at DECISION time (immutable), so execution evidence that arrived later
|
|
415
443
|
// lives on the record — surface the CURRENT tier here so the reader doesn't need a second
|
|
416
444
|
// lookup to see it (Codex catch).
|
|
@@ -418,12 +446,219 @@ async function cmdReceipt(id) {
|
|
|
418
446
|
const conf = detail.body?.record?.confirmation;
|
|
419
447
|
if (conf?.status)
|
|
420
448
|
log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
|
|
421
|
-
|
|
422
|
-
|
|
449
|
+
// Emit EXACTLY the deterministic command we document (round-4 catch: the README and this hint
|
|
450
|
+
// disagreed, and bare npx re-probes the registry) — the verifier ships INSIDE this package.
|
|
451
|
+
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
452
|
+
log(` npx @decidio/sdk verify ${name}`);
|
|
423
453
|
log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
|
|
424
|
-
log(` npx @decidio/verify --issuer <did:key:...> ${name}`);
|
|
454
|
+
log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
|
|
425
455
|
return 0;
|
|
426
456
|
}
|
|
457
|
+
// ---- verify (embedded) ----------------------------------------------------------------------
|
|
458
|
+
/** Duplicate-key-refusing parse — the SAME semantics as the decidio-verify CLI (RFC 8785 §3.1:
|
|
459
|
+
* duplicate keys make different parsers disagree on which value is real; refuse instead).
|
|
460
|
+
* Conformance-locked: the cross-language runner asserts this door refuses the same vectors for
|
|
461
|
+
* the same reasons as decidio-verify and python -m decidio.verify. */
|
|
462
|
+
function parseReceiptStrict(text, file) {
|
|
463
|
+
const value = JSON.parse(text);
|
|
464
|
+
const stack = [];
|
|
465
|
+
let i = 0;
|
|
466
|
+
const readString = () => {
|
|
467
|
+
let out = "";
|
|
468
|
+
i++;
|
|
469
|
+
while (i < text.length) {
|
|
470
|
+
const ch = text[i];
|
|
471
|
+
if (ch === "\\") {
|
|
472
|
+
out += text[i] + text[i + 1];
|
|
473
|
+
i += 2;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (ch === '"') {
|
|
477
|
+
i++;
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
out += ch;
|
|
481
|
+
i++;
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
};
|
|
485
|
+
while (i < text.length) {
|
|
486
|
+
const ch = text[i];
|
|
487
|
+
if (ch === '"') {
|
|
488
|
+
const s = readString();
|
|
489
|
+
let j = i;
|
|
490
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
491
|
+
j++;
|
|
492
|
+
if (text[j] === ":" && stack.length > 0) {
|
|
493
|
+
const key = JSON.parse(`"${s}"`);
|
|
494
|
+
const top = stack[stack.length - 1];
|
|
495
|
+
if (top.has(key))
|
|
496
|
+
throw new Error(`${file}: duplicate JSON key ${JSON.stringify(key)} — RFC 8785 requires duplicate-free input, and different parsers disagree on which value is real`);
|
|
497
|
+
top.add(key);
|
|
498
|
+
}
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (ch === "{")
|
|
502
|
+
stack.push(new Set());
|
|
503
|
+
else if (ch === "}")
|
|
504
|
+
stack.pop();
|
|
505
|
+
i++;
|
|
506
|
+
}
|
|
507
|
+
return value;
|
|
508
|
+
}
|
|
509
|
+
function cmdVerify(argsIn) {
|
|
510
|
+
const issIdx = argsIn.indexOf("--issuer");
|
|
511
|
+
const expectedIssuer = issIdx >= 0 ? argsIn[issIdx + 1] : undefined;
|
|
512
|
+
const files = argsIn.filter((a, i) => !a.startsWith("--") && !(issIdx >= 0 && i === issIdx + 1));
|
|
513
|
+
if (!files.length) {
|
|
514
|
+
log("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...]");
|
|
515
|
+
log(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
|
|
516
|
+
return 2;
|
|
517
|
+
}
|
|
518
|
+
let vcs;
|
|
519
|
+
try {
|
|
520
|
+
vcs = files.map((f) => parseReceiptStrict(readFileSync(f, "utf8"), f));
|
|
521
|
+
}
|
|
522
|
+
catch (e) {
|
|
523
|
+
log(`✘ INVALID ${String(e?.message ?? e)}`);
|
|
524
|
+
return 1;
|
|
525
|
+
}
|
|
526
|
+
let allOk = true;
|
|
527
|
+
vcs.forEach((vc, i) => {
|
|
528
|
+
const res = verifyReceipt(vc, expectedIssuer);
|
|
529
|
+
allOk = allOk && res.valid;
|
|
530
|
+
log(`${res.valid ? "✔ VALID " : "✘ INVALID"} ${files[i]}`);
|
|
531
|
+
log(` record: ${res.recordId ?? "?"} issuer: ${res.issuer}`);
|
|
532
|
+
log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
|
|
533
|
+
});
|
|
534
|
+
if (vcs.length > 1) {
|
|
535
|
+
const c = verifyChain(vcs, expectedIssuer);
|
|
536
|
+
allOk = allOk && c.valid;
|
|
537
|
+
log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.reason ? ` — ${c.reason}` : ""}`);
|
|
538
|
+
}
|
|
539
|
+
return allOk ? 0 : 1;
|
|
540
|
+
}
|
|
541
|
+
// ---- quickstart (the guided two-minute tour) ------------------------------------------------
|
|
542
|
+
async function cmdQuickstart() {
|
|
543
|
+
const t0 = Date.now();
|
|
544
|
+
log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
|
|
545
|
+
log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
|
|
546
|
+
log("");
|
|
547
|
+
const session = await acquireSession("The quickstart");
|
|
548
|
+
if (!session)
|
|
549
|
+
return 1;
|
|
550
|
+
// 1. A collision-proof agent, registered fresh every run (round-4 ask: idempotent sample
|
|
551
|
+
// registration). Key generated locally; floor-limited token minted; .env written so the
|
|
552
|
+
// take-away snippet works in this directory afterward.
|
|
553
|
+
const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
|
|
554
|
+
log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
|
|
555
|
+
const { did, privateKeyBase64 } = generateKeypair();
|
|
556
|
+
const reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
|
|
557
|
+
if (!(reg.status >= 200 && reg.status < 300)) {
|
|
558
|
+
log(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
|
|
559
|
+
return 1;
|
|
560
|
+
}
|
|
561
|
+
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart" }) }, session);
|
|
562
|
+
if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
|
|
563
|
+
log(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
const envOut = {
|
|
567
|
+
DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
|
|
568
|
+
DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
|
|
569
|
+
DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "", DECIDIO_WEBHOOK_SECRET: readEnvFile().DECIDIO_WEBHOOK_SECRET || randomBytes(32).toString("hex"),
|
|
570
|
+
};
|
|
571
|
+
// NEVER clobber a real agent's key (guard catch: keys never leave this machine — overwriting
|
|
572
|
+
// .env's DECIDIO_AGENT_KEY orphans that agent irrecoverably). A directory that already holds
|
|
573
|
+
// one gets a TOUR-SCOPED env file instead.
|
|
574
|
+
let envFile = ENV_PATH;
|
|
575
|
+
if (readEnvFile().DECIDIO_AGENT_KEY) {
|
|
576
|
+
envFile = ".env.quickstart";
|
|
577
|
+
writeFileSync(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
578
|
+
log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
upsertEnv(envOut);
|
|
582
|
+
}
|
|
583
|
+
ensureGitignore();
|
|
584
|
+
log(` ✔ key generated locally (public half registered) · floor-limited token minted · ${envFile} written`);
|
|
585
|
+
log("");
|
|
586
|
+
// 2. Gate a demo action — the same one line you'd wrap your real function with.
|
|
587
|
+
log(`② Gating a demo action with ONE line — guard.protect(payInvoice, …):`);
|
|
588
|
+
log("");
|
|
589
|
+
log(` const payInvoice = guard.protect(payInvoiceRaw,`);
|
|
590
|
+
log(` (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }));`);
|
|
591
|
+
log("");
|
|
592
|
+
let decisionId = "";
|
|
593
|
+
const cfg = {
|
|
594
|
+
apiUrl: API, agentId, apiToken: mint.body.token, agentKey: privateKeyBase64,
|
|
595
|
+
agentDid: reg.body?.did ?? did, workspaceId: mint.body.workspaceId,
|
|
596
|
+
pollIntervalMs: 1500, pollTimeoutMs: 300_000,
|
|
597
|
+
onPending: (p) => { decisionId = p.decisionId; },
|
|
598
|
+
};
|
|
599
|
+
const payInvoice = withApproval(async (inv) => ({ paid: true, id: inv.id }), (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), cfg);
|
|
600
|
+
const running = payInvoice({ id: "INV-2026-001", amount: 86_000 });
|
|
601
|
+
running.catch(() => { });
|
|
602
|
+
const waitStart = Date.now();
|
|
603
|
+
while (!decisionId && Date.now() - waitStart < 30_000)
|
|
604
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
605
|
+
if (!decisionId) {
|
|
606
|
+
log("✘ the gate did not route within 30s — check connectivity (npx @decidio/sdk doctor) and re-run.");
|
|
607
|
+
return 1;
|
|
608
|
+
}
|
|
609
|
+
log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
|
|
610
|
+
log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
|
|
611
|
+
log("");
|
|
612
|
+
// 3. The human moment — approve right here, or from the Decidio app.
|
|
613
|
+
if (process.stdin.isTTY) {
|
|
614
|
+
await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
log("③ Headless terminal — auto-approving with your session so the tour can continue.");
|
|
618
|
+
}
|
|
619
|
+
const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: "quickstart" }) }, session);
|
|
620
|
+
if (!(ap.status >= 200 && ap.status < 300)) {
|
|
621
|
+
log(`✘ approve failed (${ap.status}): ${JSON.stringify(ap.body)}`);
|
|
622
|
+
return 1;
|
|
623
|
+
}
|
|
624
|
+
log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
|
|
625
|
+
// 4. The blocking wrapper wakes and runs the agent's own function.
|
|
626
|
+
let result;
|
|
627
|
+
try {
|
|
628
|
+
result = await running;
|
|
629
|
+
}
|
|
630
|
+
catch (e) {
|
|
631
|
+
log(`✘ the gated call did not complete: ${e?.message ?? e}`);
|
|
632
|
+
return 1;
|
|
633
|
+
}
|
|
634
|
+
log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
|
|
635
|
+
log("");
|
|
636
|
+
// 5. The proof: download the receipt and verify it offline, all in-process.
|
|
637
|
+
log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
|
|
638
|
+
const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
|
|
639
|
+
if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
|
|
640
|
+
log(`✘ receipt fetch failed (${rec.status})`);
|
|
641
|
+
return 1;
|
|
642
|
+
}
|
|
643
|
+
const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
|
|
644
|
+
writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
645
|
+
const v = verifyReceipt(rec.body.credential, undefined);
|
|
646
|
+
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding verified with NO network, no Decidio account)`);
|
|
647
|
+
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
648
|
+
const tier = detail.body?.record?.confirmation?.status;
|
|
649
|
+
if (tier)
|
|
650
|
+
log(` ✔ execution evidence on the record: ${tier} — the wrapper's report was cryptographically bound to this agent`);
|
|
651
|
+
log("");
|
|
652
|
+
const secs = Math.round((Date.now() - t0) / 1000);
|
|
653
|
+
log(`Done in ${secs}s: gated → routed to a human → approved → executed once → sealed, verifiable proof in your hands.`);
|
|
654
|
+
log("");
|
|
655
|
+
log("Take it to your real agent — the .env here already works:");
|
|
656
|
+
log(` import { guard } from "@decidio/sdk";`);
|
|
657
|
+
log(` const doIt = guard.protect(doItRaw, (a) => ({ action: "yourAction", amount: a.amount, scope: "YourScope" }));`);
|
|
658
|
+
log("");
|
|
659
|
+
log("Next: the README's 7-step quickstart (durable mode, workers, adapters) · your queue at your Decidio app.");
|
|
660
|
+
return v.valid ? 0 : 1;
|
|
661
|
+
}
|
|
427
662
|
function cmdDev(target, port) {
|
|
428
663
|
// Signal-only relay: receive Decidio's webhook here (a stable URL) and forward it to
|
|
429
664
|
// your local agent's resume listener. Useful when the agent isn't directly reachable.
|
|
@@ -453,15 +688,27 @@ function cmdDev(target, port) {
|
|
|
453
688
|
}
|
|
454
689
|
const USAGE = `decidio — the Decidio SDK command line
|
|
455
690
|
|
|
691
|
+
New here? See the whole thing work in about two minutes:
|
|
692
|
+
|
|
693
|
+
npx @decidio/sdk quickstart
|
|
694
|
+
|
|
695
|
+
It gates a demo action, routes it to YOU for approval, executes on your yes, and hands you a
|
|
696
|
+
cryptographically verifiable receipt. (Guided and explicit — this CLI never acts, signs you
|
|
697
|
+
in, or writes files unless you ask it to.)
|
|
698
|
+
|
|
699
|
+
npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
|
|
456
700
|
npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
|
|
701
|
+
(--unique appends a random suffix — collision-proof samples)
|
|
457
702
|
npx @decidio/sdk doctor check config, connectivity, and what your token can do
|
|
458
|
-
npx @decidio/sdk approvals
|
|
703
|
+
npx @decidio/sdk approvals [--json] list pending agent approvals
|
|
459
704
|
npx @decidio/sdk approvals approve <id> [reason]
|
|
460
705
|
npx @decidio/sdk approvals reject <id> [reason]
|
|
461
706
|
npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
|
|
707
|
+
npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
|
|
462
708
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
463
709
|
npx @decidio/sdk --version print the CLI version
|
|
464
710
|
|
|
711
|
+
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
|
|
465
712
|
Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
|
|
466
713
|
async function main(argv) {
|
|
467
714
|
const [cmd, a, b, ...rest] = argv.slice(2);
|
|
@@ -479,10 +726,20 @@ async function main(argv) {
|
|
|
479
726
|
return 0;
|
|
480
727
|
}
|
|
481
728
|
switch (cmd) {
|
|
482
|
-
case "
|
|
729
|
+
case "quickstart": return cmdQuickstart();
|
|
730
|
+
case "init": {
|
|
731
|
+
const argsIn = argv.slice(3);
|
|
732
|
+
const unique = argsIn.includes("--unique");
|
|
733
|
+
const nameArg = argsIn.find((x) => !x.startsWith("-"));
|
|
734
|
+
let name = nameArg ?? process.env.DECIDIO_AGENT_ID ?? "my-agent";
|
|
735
|
+
if (unique)
|
|
736
|
+
name = `${name}-${randomBytes(2).toString("hex")}`;
|
|
737
|
+
return cmdInit(name);
|
|
738
|
+
}
|
|
483
739
|
case "doctor": return cmdDoctor();
|
|
484
740
|
case "approvals": return cmdApprovals(a, b, rest.join(" ") || undefined);
|
|
485
741
|
case "receipt": return cmdReceipt(a);
|
|
742
|
+
case "verify": return cmdVerify(argv.slice(3));
|
|
486
743
|
case "dev": {
|
|
487
744
|
const args = argv.slice(3);
|
|
488
745
|
const flag = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
package/dist/index.d.ts
CHANGED
|
@@ -75,6 +75,16 @@ export declare class DecidioTimeoutError extends Error {
|
|
|
75
75
|
decisionId: string;
|
|
76
76
|
constructor(decisionId: string);
|
|
77
77
|
}
|
|
78
|
+
/** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
|
|
79
|
+
* wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
|
|
80
|
+
* endpoint, the operation, the underlying code, and the safety state a caller cares about most:
|
|
81
|
+
* whether anything could have executed. */
|
|
82
|
+
export declare class DecidioNetworkError extends Error {
|
|
83
|
+
endpoint: string;
|
|
84
|
+
op: string;
|
|
85
|
+
code: string | undefined;
|
|
86
|
+
constructor(endpoint: string, op: string, code: string | undefined, cause: unknown);
|
|
87
|
+
}
|
|
78
88
|
/**
|
|
79
89
|
* Wrap an async action behind the Decidio gate. `describe` extracts the approval
|
|
80
90
|
* context (action, amount, scope) from the call arguments. Returns a function with
|
package/dist/index.js
CHANGED
|
@@ -61,9 +61,37 @@ export class DecidioTimeoutError extends Error {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
64
|
+
/** A transport failure with GUIDANCE instead of a raw undici stack (round-4 Codex catch: the CLI
|
|
65
|
+
* wrapped network errors kindly but the runtime leaked `TypeError: fetch failed`). Carries the
|
|
66
|
+
* endpoint, the operation, the underlying code, and the safety state a caller cares about most:
|
|
67
|
+
* whether anything could have executed. */
|
|
68
|
+
export class DecidioNetworkError extends Error {
|
|
69
|
+
endpoint;
|
|
70
|
+
op;
|
|
71
|
+
code;
|
|
72
|
+
constructor(endpoint, op, code, cause) {
|
|
73
|
+
super(`[decidio] could not reach ${endpoint} during ${op}${code ? ` (${code})` : ""}. ` +
|
|
74
|
+
`Nothing was executed locally and no verdict was received — if a request reached Decidio it is ` +
|
|
75
|
+
`visible in your queue. Check the network/URL (DECIDIO_API_URL) and retry; retrying is safe ` +
|
|
76
|
+
`(a retry creates a new request — it can never double-execute).`);
|
|
77
|
+
this.endpoint = endpoint;
|
|
78
|
+
this.op = op;
|
|
79
|
+
this.code = code;
|
|
80
|
+
this.name = "DecidioNetworkError";
|
|
81
|
+
this.cause = cause;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function reach(f, url, op, init) {
|
|
85
|
+
try {
|
|
86
|
+
return await f(url, init);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
throw new DecidioNetworkError(url, op, e?.cause?.code ?? e?.code, e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
64
92
|
async function post(cfg, path, body) {
|
|
65
93
|
const f = cfg.fetchImpl ?? fetch;
|
|
66
|
-
const res = await f
|
|
94
|
+
const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
|
|
67
95
|
method: "POST",
|
|
68
96
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
69
97
|
body: JSON.stringify(body),
|
|
@@ -74,7 +102,7 @@ async function post(cfg, path, body) {
|
|
|
74
102
|
}
|
|
75
103
|
async function get(cfg, path) {
|
|
76
104
|
const f = cfg.fetchImpl ?? fetch;
|
|
77
|
-
const res = await f
|
|
105
|
+
const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${path}`, {
|
|
78
106
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
79
107
|
});
|
|
80
108
|
if (!res.ok)
|
|
@@ -190,6 +218,10 @@ export function withApproval(fn, describe, config) {
|
|
|
190
218
|
source: "wrapper", // the SDK captured the genuine response, not an agent self-report
|
|
191
219
|
...(confirmIdentity ? { requesterIdentity: confirmIdentity } : {}),
|
|
192
220
|
});
|
|
221
|
+
// An UNSIGNED confirm is server-capped at the weaker agent_asserted tier. Say so at the
|
|
222
|
+
// moment it happens — a silent evidence downgrade was round 3's acceptance FAIL.
|
|
223
|
+
if (!confirmIdentity)
|
|
224
|
+
console.warn(`[decidio] execution evidence for ${auth.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 (npx @decidio/sdk init writes them) to earn application_confirmed.`);
|
|
193
225
|
}
|
|
194
226
|
catch (e) {
|
|
195
227
|
// surface but don't throw — the action already happened
|
package/dist/resume.js
CHANGED
|
@@ -110,6 +110,10 @@ async function confirm(cfg, decisionId, capturedResponse) {
|
|
|
110
110
|
// Sign the confirm so the DURABLE/resume path can earn application_confirmed too (bound to
|
|
111
111
|
// {action:"confirm", scope:decisionId}); without a key the server caps it at agent_asserted.
|
|
112
112
|
const requesterIdentity = signConfirm(decisionId, cfg);
|
|
113
|
+
// Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch): the durable
|
|
114
|
+
// path is what production agents run, so it must disclose an agent_asserted cap too.
|
|
115
|
+
if (!requesterIdentity)
|
|
116
|
+
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.`);
|
|
113
117
|
await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
114
118
|
method: "POST",
|
|
115
119
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
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.4
|
|
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.4",
|
|
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",
|
|
@@ -79,5 +79,8 @@
|
|
|
79
79
|
"typecheck": "tsc --noEmit",
|
|
80
80
|
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
81
81
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
82
|
+
},
|
|
83
|
+
"dependencies": {
|
|
84
|
+
"@decidio/verify": "^0.1.3"
|
|
82
85
|
}
|
|
83
86
|
}
|