@decidio/sdk 0.1.10 → 0.1.12
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 +126 -25
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +76 -9
- package/dist/adapters/langgraph.js +2 -1
- package/dist/adapters/openai.js +2 -1
- package/dist/adapters/temporal.js +17 -3
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +128 -0
- package/dist/cli.js +182 -30
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/evidence.js +65 -0
- package/dist/guard.d.ts +22 -3
- package/dist/guard.js +18 -11
- package/dist/index.d.ts +13 -2
- package/dist/index.js +58 -29
- package/dist/inngest.js +6 -30
- package/dist/resume.d.ts +78 -17
- package/dist/resume.js +226 -55
- package/dist/signing.d.ts +64 -0
- package/dist/signing.js +102 -3
- package/openapi.yaml +47 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -6,8 +6,8 @@ import { createServer } from "node:http";
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
|
-
import { generateKeypair } from "./signing.js";
|
|
10
|
-
import { withApproval, DecidioRateLimitError } from "./index.js";
|
|
9
|
+
import { generateKeypair, assertSafeTransport } from "./signing.js";
|
|
10
|
+
import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
|
|
11
11
|
import { verifyReceipt, runVerify } from "@decidio/verify";
|
|
12
12
|
const VERSION = (() => {
|
|
13
13
|
try {
|
|
@@ -18,6 +18,7 @@ const VERSION = (() => {
|
|
|
18
18
|
}
|
|
19
19
|
})();
|
|
20
20
|
let envWarned = false;
|
|
21
|
+
let envUnreadable = false;
|
|
21
22
|
function readEnvFile() {
|
|
22
23
|
const out = {};
|
|
23
24
|
try {
|
|
@@ -29,9 +30,10 @@ function readEnvFile() {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
catch (e) {
|
|
33
|
+
envUnreadable = true;
|
|
32
34
|
if (!envWarned) {
|
|
33
35
|
envWarned = true;
|
|
34
|
-
console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e})
|
|
36
|
+
console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e}).`);
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
return out;
|
|
@@ -58,10 +60,20 @@ async function api(path, init = {}, bearer) {
|
|
|
58
60
|
err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
|
|
59
61
|
}
|
|
60
62
|
const auth = bearer ?? TOKEN();
|
|
61
|
-
if (auth &&
|
|
62
|
-
err(`✘
|
|
63
|
+
if (auth && envUnreadable && !process.env.DECIDIO_API_URL) {
|
|
64
|
+
err(`✘ .env exists here but could not be read, so the target Decidio is unknown — refusing to send credentials to a guessed host (${API}).`);
|
|
65
|
+
err(` Fix the file's permissions, or set DECIDIO_API_URL explicitly for this command. Nothing was sent.`);
|
|
63
66
|
process.exit(1);
|
|
64
67
|
}
|
|
68
|
+
if (auth) {
|
|
69
|
+
try {
|
|
70
|
+
assertSafeTransport(API, true);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
65
77
|
let res;
|
|
66
78
|
try {
|
|
67
79
|
res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...(auth ? { authorization: `Bearer ${auth}` } : {}), ...(init.headers ?? {}) } });
|
|
@@ -137,6 +149,28 @@ function upsertEnv(updates) {
|
|
|
137
149
|
lines.push(`${k}=${v}`);
|
|
138
150
|
writeSecretFile(ENV_PATH, lines.join("\n") + "\n");
|
|
139
151
|
}
|
|
152
|
+
function isTrackedByGit(path) {
|
|
153
|
+
try {
|
|
154
|
+
execFileSync("git", ["ls-files", "--error-unmatch", path], { stdio: "ignore" });
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function assertNotTracked(path) {
|
|
162
|
+
if (!isTrackedByGit(path))
|
|
163
|
+
return true;
|
|
164
|
+
err(`✘ ${path} is ALREADY TRACKED by git — refusing to write the agent's private key into it.`);
|
|
165
|
+
err(` A .gitignore entry does not untrack a file: the next \`git add -A\` would commit`);
|
|
166
|
+
err(` DECIDIO_AGENT_KEY and DECIDIO_API_TOKEN into permanent history. Nothing was written.`);
|
|
167
|
+
err(` Fix it, then re-run:`);
|
|
168
|
+
err(` git rm --cached ${path} # stop tracking it (keeps your local copy)`);
|
|
169
|
+
err(` git commit -m "stop tracking ${path}"`);
|
|
170
|
+
err(` If it has ALREADY been committed with secrets in it, rotate them — history rewriting`);
|
|
171
|
+
err(` alone does not un-leak a key that was pushed.`);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
140
174
|
function ensureGitignore() {
|
|
141
175
|
const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
|
|
142
176
|
const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
@@ -144,7 +178,7 @@ function ensureGitignore() {
|
|
|
144
178
|
if (!missing.length)
|
|
145
179
|
return;
|
|
146
180
|
appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
|
|
147
|
-
log(`✔ added ${missing.join(" + ")} to .gitignore (
|
|
181
|
+
log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
|
|
148
182
|
}
|
|
149
183
|
function ask(question, mute = false) {
|
|
150
184
|
return new Promise((resolve) => {
|
|
@@ -160,7 +194,10 @@ function ask(question, mute = false) {
|
|
|
160
194
|
});
|
|
161
195
|
}
|
|
162
196
|
async function acquireSession(purpose) {
|
|
163
|
-
|
|
197
|
+
try {
|
|
198
|
+
assertSafeTransport(API, true);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
164
201
|
err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
|
|
165
202
|
err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
|
|
166
203
|
return null;
|
|
@@ -204,6 +241,8 @@ async function acquireSession(purpose) {
|
|
|
204
241
|
return null;
|
|
205
242
|
}
|
|
206
243
|
async function cmdInit(agentId, opts = {}) {
|
|
244
|
+
if (!assertNotTracked(ENV_PATH))
|
|
245
|
+
return 1;
|
|
207
246
|
err(`Registering agent "${agentId}" with Decidio at ${API}…`);
|
|
208
247
|
const existingEnv = readEnvFile();
|
|
209
248
|
const existingKey = existingEnv.DECIDIO_AGENT_KEY;
|
|
@@ -342,8 +381,20 @@ async function cmdDoctor() {
|
|
|
342
381
|
log(secret ? "✔ webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)"
|
|
343
382
|
: "• webhook secret: not set — only needed for the operator resume-webhook transport; the poll worker guard.worker() is the self-serve resume path (init generates one)");
|
|
344
383
|
const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
345
|
-
if (signing)
|
|
346
|
-
|
|
384
|
+
if (signing) {
|
|
385
|
+
const storedDid = (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
386
|
+
let derived;
|
|
387
|
+
try {
|
|
388
|
+
derived = didFromPrivateKey((process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY));
|
|
389
|
+
}
|
|
390
|
+
catch { }
|
|
391
|
+
if (!derived)
|
|
392
|
+
line(false, "signing key: PRESENT BUT UNREADABLE — DECIDIO_AGENT_KEY is not a valid Ed25519 private key. Re-run `npx @decidio/sdk init` (its public half is what Decidio registered).");
|
|
393
|
+
else if (derived !== storedDid)
|
|
394
|
+
line(false, `signing key: DOES NOT MATCH this agent's DID — the key here derives ${derived}, but .env registers ${storedDid}. Signed requests will not be recognised as this agent (auto-approve and application_confirmed evidence will both fail). Restore the original key, or re-run \`npx @decidio/sdk init\` to register a fresh one.`);
|
|
395
|
+
else
|
|
396
|
+
log("✔ signing key: set and MATCHES the registered DID — 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).");
|
|
397
|
+
}
|
|
347
398
|
else
|
|
348
399
|
warn("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.");
|
|
349
400
|
const h = await api("/api/health", {}, "");
|
|
@@ -361,12 +412,13 @@ async function cmdDoctor() {
|
|
|
361
412
|
const msLeft = payload.exp * 1000 - Date.now();
|
|
362
413
|
const days = Math.floor((msLeft + 60_000) / 86_400_000);
|
|
363
414
|
const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
|
|
415
|
+
const daysRounded = Math.round(msLeft / 86_400_000);
|
|
364
416
|
if (msLeft < 0)
|
|
365
417
|
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.`);
|
|
366
418
|
else if (days <= 14)
|
|
367
419
|
warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
|
|
368
420
|
else
|
|
369
|
-
log(`✔ agent token:
|
|
421
|
+
log(`✔ agent token: unexpired for another ${daysRounded} days (validity is decided by the server — see the token-scope check below)`);
|
|
370
422
|
}
|
|
371
423
|
}
|
|
372
424
|
catch { }
|
|
@@ -396,6 +448,11 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
|
396
448
|
sub = undefined;
|
|
397
449
|
if ((sub === "approve" || sub === "reject") && !id) {
|
|
398
450
|
err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
|
|
451
|
+
err(` npx @decidio/sdk approvals ${sub} <decisionId> --reason "text with -dashes"`);
|
|
452
|
+
return 2;
|
|
453
|
+
}
|
|
454
|
+
if ((sub === "approve" || sub === "reject") && (flags.json || flags.mine)) {
|
|
455
|
+
err(`✘ ${flags.json ? "--json" : "--mine"} applies to \`approvals\` (the list), not to ${sub} — refusing to ignore a flag you meant to matter.`);
|
|
399
456
|
return 2;
|
|
400
457
|
}
|
|
401
458
|
if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
|
|
@@ -422,8 +479,18 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
|
422
479
|
return 1;
|
|
423
480
|
}
|
|
424
481
|
log(`✔ ${sub === "approve" ? "approved" : "rejected"} ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
|
|
425
|
-
if (
|
|
426
|
-
log(`
|
|
482
|
+
if (reason) {
|
|
483
|
+
log(` reason sent verbatim: ${JSON.stringify(reason)}`);
|
|
484
|
+
log(` (it seals immutably; personal or sensitive values are tokenized at rest, so the`);
|
|
485
|
+
log(` stored text can differ — see exactly what was kept: npx @decidio/sdk receipt ${id})`);
|
|
486
|
+
}
|
|
487
|
+
if (sub === "approve") {
|
|
488
|
+
log(` What happens next depends on how the agent is waiting:`);
|
|
489
|
+
log(` • durable (the production mode): its worker or resume route picks this up and executes.`);
|
|
490
|
+
log(` • blocking, still running: it resumes now.`);
|
|
491
|
+
log(` • blocking, already timed out or exited: NOTHING executes — that process is gone, and this`);
|
|
492
|
+
log(` approval resolved the RECORD only. Re-run the action to request again.`);
|
|
493
|
+
}
|
|
427
494
|
else
|
|
428
495
|
log(` The agent's action will NOT run; the rejection seals as a verifiable record.`);
|
|
429
496
|
log(` Download the receipt: npx @decidio/sdk receipt ${id}`);
|
|
@@ -517,11 +584,13 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
517
584
|
if (conf?.status)
|
|
518
585
|
log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
|
|
519
586
|
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
520
|
-
log(` npx @decidio/sdk verify ${name}`);
|
|
521
587
|
if (issuer) {
|
|
522
|
-
log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
|
|
523
|
-
log(` ${issuer}`);
|
|
524
588
|
log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
|
|
589
|
+
log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
|
|
590
|
+
log(` response above — record it once, then pin it forever):`);
|
|
591
|
+
log(` ${issuer}`);
|
|
592
|
+
log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
|
|
593
|
+
log(` issued it; it says so in its output.`);
|
|
525
594
|
}
|
|
526
595
|
else {
|
|
527
596
|
log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
|
|
@@ -536,8 +605,8 @@ function cmdVerify(argsIn) {
|
|
|
536
605
|
const a = argsIn[i];
|
|
537
606
|
if (a === "--issuer") {
|
|
538
607
|
const v = argsIn[i + 1];
|
|
539
|
-
if (v === undefined || v.startsWith("-")) {
|
|
540
|
-
err("✘ --issuer needs a did:key value.");
|
|
608
|
+
if (v === undefined || v === "" || v.startsWith("-")) {
|
|
609
|
+
err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
|
|
541
610
|
return 2;
|
|
542
611
|
}
|
|
543
612
|
expectedIssuer = v;
|
|
@@ -587,12 +656,27 @@ async function cmdQuickstart(opts = {}) {
|
|
|
587
656
|
const session = await acquireSession("The quickstart");
|
|
588
657
|
if (!session)
|
|
589
658
|
return 1;
|
|
590
|
-
|
|
591
|
-
log(`① Registering agent "${agentId}"
|
|
659
|
+
let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
|
|
660
|
+
log(`① Registering agent "${agentId}"…`);
|
|
592
661
|
const { did, privateKeyBase64 } = generateKeypair();
|
|
593
|
-
|
|
662
|
+
let reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
|
|
663
|
+
for (let attempt = 0; reg.status === 409 && attempt < 3; attempt++) {
|
|
664
|
+
agentId = `quickstart-${randomBytes(5).toString("hex")}`;
|
|
665
|
+
reg = await api("/api/agents/register", { method: "POST", body: JSON.stringify({ agentId, did }) }, session);
|
|
666
|
+
if (reg.status >= 200 && reg.status < 300)
|
|
667
|
+
log(` (that name was taken — using "${agentId}" instead)`);
|
|
668
|
+
}
|
|
594
669
|
if (!(reg.status >= 200 && reg.status < 300)) {
|
|
595
|
-
err(`✘
|
|
670
|
+
err(`✘ could not register the tour's agent (${reg.status}): ${JSON.stringify(reg.body)}`);
|
|
671
|
+
if (reg.status === 409) {
|
|
672
|
+
err(` Every generated name collided, which should be almost impossible — something is likely`);
|
|
673
|
+
err(` wrong with this workspace's agent list rather than with your setup.`);
|
|
674
|
+
err(` See what is registered: npx @decidio/sdk tokens list`);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
err(` Check the target and your sign-in, then re-run: npx @decidio/sdk doctor`);
|
|
678
|
+
}
|
|
679
|
+
err(` Nothing was written to this directory.`);
|
|
596
680
|
return 1;
|
|
597
681
|
}
|
|
598
682
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
|
|
@@ -609,6 +693,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
609
693
|
let envFile = ENV_PATH;
|
|
610
694
|
if (readEnvFile().DECIDIO_AGENT_KEY) {
|
|
611
695
|
envFile = ".env.quickstart";
|
|
696
|
+
if (!assertNotTracked(envFile))
|
|
697
|
+
return 1;
|
|
612
698
|
ensureGitignore();
|
|
613
699
|
if (existsSync(envFile)) {
|
|
614
700
|
const prev = readFileSync(envFile, "utf8");
|
|
@@ -623,6 +709,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
623
709
|
log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
|
|
624
710
|
}
|
|
625
711
|
else {
|
|
712
|
+
if (!assertNotTracked(ENV_PATH))
|
|
713
|
+
return 1;
|
|
626
714
|
ensureGitignore();
|
|
627
715
|
upsertEnv(envOut);
|
|
628
716
|
}
|
|
@@ -685,7 +773,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
685
773
|
log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
|
|
686
774
|
log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
|
|
687
775
|
log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
|
|
688
|
-
|
|
776
|
+
log("");
|
|
777
|
+
log(" (exit 3 = the tour stopped at the human moment. Nothing executed and no receipt was");
|
|
778
|
+
log(" written, so this run is deliberately not reported as a completed tour.)");
|
|
779
|
+
process.exit(3);
|
|
689
780
|
}
|
|
690
781
|
const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
|
|
691
782
|
if (!(ap.status >= 200 && ap.status < 300)) {
|
|
@@ -713,7 +804,13 @@ async function cmdQuickstart(opts = {}) {
|
|
|
713
804
|
writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
714
805
|
const issuer = rec.body?.issuer;
|
|
715
806
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
716
|
-
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "
|
|
807
|
+
log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : ""})`);
|
|
808
|
+
if (!issuer) {
|
|
809
|
+
err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
|
|
810
|
+
err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
|
|
811
|
+
err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
|
|
812
|
+
err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
|
|
813
|
+
}
|
|
717
814
|
if (issuer)
|
|
718
815
|
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
719
816
|
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
@@ -762,11 +859,20 @@ async function quickstartCleanup(session, envFile) {
|
|
|
762
859
|
log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
|
|
763
860
|
else if (rev.status >= 200 && rev.status < 300)
|
|
764
861
|
log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
|
|
765
|
-
else
|
|
766
|
-
|
|
862
|
+
else {
|
|
863
|
+
err(`✘ token revoke returned ${rev.status} — the tour's token is STILL LIVE (it would expire on its own within 7 days).`);
|
|
864
|
+
err(` Nothing was deleted: ${envFile} still holds the jti so you can retry. Run \`npx @decidio/sdk quickstart cleanup\` again, or revoke it directly:`);
|
|
865
|
+
err(` npx @decidio/sdk tokens revoke ${env.DECIDIO_TOKEN_JTI}`);
|
|
866
|
+
return 1;
|
|
867
|
+
}
|
|
767
868
|
}
|
|
768
869
|
else {
|
|
769
|
-
|
|
870
|
+
err(`✘ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — this tour's token is STILL LIVE and this`);
|
|
871
|
+
err(` command cannot revoke it. Nothing was deleted. Find and revoke it, then re-run cleanup:`);
|
|
872
|
+
err(` npx @decidio/sdk tokens list`);
|
|
873
|
+
err(` npx @decidio/sdk tokens revoke <jti>`);
|
|
874
|
+
err(` (it also expires on its own within 7 days)`);
|
|
875
|
+
return 1;
|
|
770
876
|
}
|
|
771
877
|
if (existsSync(envFile)) {
|
|
772
878
|
const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
|
|
@@ -916,6 +1022,12 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
916
1022
|
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
917
1023
|
npx @decidio/sdk approvals approve <id> [reason]
|
|
918
1024
|
npx @decidio/sdk approvals reject <id> [reason]
|
|
1025
|
+
the reason is FREE TEXT, sent exactly as typed. It seals
|
|
1026
|
+
immutably; personal or sensitive values are tokenized at
|
|
1027
|
+
rest, so the STORED text can differ (see: receipt <id>).
|
|
1028
|
+
If it starts with a dash, put it after -- or use --reason:
|
|
1029
|
+
... approve <id> -- "-50% cut, per the board"
|
|
1030
|
+
... approve <id> --reason "-50% cut, per the board"
|
|
919
1031
|
npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
|
|
920
1032
|
npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
|
|
921
1033
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
@@ -924,7 +1036,9 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
924
1036
|
Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
|
|
925
1037
|
DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
|
|
926
1038
|
fully offline — it touches no network and reads no .env.
|
|
927
|
-
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error
|
|
1039
|
+
Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
|
|
1040
|
+
3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
|
|
1041
|
+
receipt written — pass --auto-approve-demo for a run that completes unattended).
|
|
928
1042
|
Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
|
|
929
1043
|
pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
|
|
930
1044
|
Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
|
|
@@ -949,7 +1063,7 @@ async function main(argv) {
|
|
|
949
1063
|
quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
|
|
950
1064
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
951
1065
|
doctor: { flags: [] },
|
|
952
|
-
approvals: { flags: ["--json", "--mine"] },
|
|
1066
|
+
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
953
1067
|
receipt: { flags: ["--json"] },
|
|
954
1068
|
verify: { flags: [], valued: ["--issuer"] },
|
|
955
1069
|
tokens: { flags: ["--mine"] },
|
|
@@ -961,6 +1075,17 @@ async function main(argv) {
|
|
|
961
1075
|
const positionals = [];
|
|
962
1076
|
for (let i = 0; i < argsIn.length; i++) {
|
|
963
1077
|
const a = argsIn[i];
|
|
1078
|
+
if (a === "--") {
|
|
1079
|
+
positionals.push(...argsIn.slice(i + 1));
|
|
1080
|
+
break;
|
|
1081
|
+
}
|
|
1082
|
+
const inFreeTextTail = cmd === "approvals"
|
|
1083
|
+
&& (positionals[0] === "approve" || positionals[0] === "reject")
|
|
1084
|
+
&& positionals.length >= 2;
|
|
1085
|
+
if (!inFreeTextTail && a.startsWith("-") && !a.startsWith("--")) {
|
|
1086
|
+
err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored). Short flags are not supported; use the long form.\n\n${USAGE}`);
|
|
1087
|
+
return 2;
|
|
1088
|
+
}
|
|
964
1089
|
if (!a.startsWith("--")) {
|
|
965
1090
|
positionals.push(a);
|
|
966
1091
|
continue;
|
|
@@ -1028,8 +1153,35 @@ async function main(argv) {
|
|
|
1028
1153
|
}
|
|
1029
1154
|
case "doctor": return cmdDoctor();
|
|
1030
1155
|
case "approvals": {
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1156
|
+
const dashdash = argsIn.indexOf("--");
|
|
1157
|
+
const head = dashdash >= 0 ? argsIn.slice(0, dashdash) : argsIn;
|
|
1158
|
+
const literal = dashdash >= 0 ? argsIn.slice(dashdash + 1) : [];
|
|
1159
|
+
const rIdx = head.indexOf("--reason");
|
|
1160
|
+
const rEq = head.find((x) => x.startsWith("--reason="));
|
|
1161
|
+
const flagless = head.filter((x, i) => !x.startsWith("-") && !(rIdx >= 0 && i === rIdx + 1));
|
|
1162
|
+
const sub = flagless[0];
|
|
1163
|
+
const id = flagless[1];
|
|
1164
|
+
let reason;
|
|
1165
|
+
if (rIdx >= 0)
|
|
1166
|
+
reason = head[rIdx + 1];
|
|
1167
|
+
else if (rEq !== undefined)
|
|
1168
|
+
reason = rEq.slice("--reason=".length);
|
|
1169
|
+
else if (literal.length)
|
|
1170
|
+
reason = literal.join(" ");
|
|
1171
|
+
else if (sub && id) {
|
|
1172
|
+
const after = head.indexOf(id) + 1;
|
|
1173
|
+
const rest = head.slice(after).filter((x) => x !== "--json" && x !== "--mine");
|
|
1174
|
+
reason = rest.length ? rest.join(" ") : undefined;
|
|
1175
|
+
}
|
|
1176
|
+
if ((rIdx >= 0 || rEq !== undefined) && (reason === undefined || reason === "" || (rIdx >= 0 && reason.startsWith("--")))) {
|
|
1177
|
+
err(`✘ --reason needs the text to record (quote it if it contains spaces): npx @decidio/sdk approvals ${sub ?? "approve"} <id> --reason "over budget -50% cut required"`);
|
|
1178
|
+
return 2;
|
|
1179
|
+
}
|
|
1180
|
+
if (reason !== undefined && sub !== "approve" && sub !== "reject") {
|
|
1181
|
+
err(`✘ --reason only applies to approve/reject (a sealed reason needs a decision to seal onto): npx @decidio/sdk approvals approve <id> --reason "..."`);
|
|
1182
|
+
return 2;
|
|
1183
|
+
}
|
|
1184
|
+
return cmdApprovals(sub, id, reason, { json: head.includes("--json"), mine: head.includes("--mine") });
|
|
1033
1185
|
}
|
|
1034
1186
|
case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
|
|
1035
1187
|
case "verify": return cmdVerify(argsIn);
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A gate response this SDK could not accept, carrying what a caller needs to BRANCH on rather
|
|
2
|
+
* than a stringified body they would have to parse. `status` is the HTTP status; `retryable`
|
|
3
|
+
* distinguishes a terminal refusal (401/403 — a credential problem no retry fixes) from a
|
|
4
|
+
* transient one (5xx, 429). Every long-running agent hits the terminal case eventually: an
|
|
5
|
+
* agent token expires, or `init` is re-run and revokes it. */
|
|
6
|
+
export declare class DecidioHttpError extends Error {
|
|
7
|
+
status: number;
|
|
8
|
+
endpoint: string;
|
|
9
|
+
body?: unknown | undefined;
|
|
10
|
+
readonly retryable: boolean;
|
|
11
|
+
constructor(status: number, endpoint: string, body?: unknown | undefined);
|
|
12
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class DecidioHttpError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
endpoint;
|
|
4
|
+
body;
|
|
5
|
+
retryable;
|
|
6
|
+
constructor(status, endpoint, body) {
|
|
7
|
+
const terminal = status === 401 || status === 403;
|
|
8
|
+
super(`[decidio] ${endpoint} refused this call (HTTP ${status})` +
|
|
9
|
+
(terminal
|
|
10
|
+
? ` — the credential was rejected. The agent token may have expired or been revoked (re-running \`init\` for the same agent revokes the previous one). Mint a fresh one: npx @decidio/sdk init <agent-name>, then restart this process. Nothing was routed or executed.`
|
|
11
|
+
: ` — this looks transient; retrying is safe (a retry creates a NEW request and can never double-execute). Nothing was routed or executed.`));
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.endpoint = endpoint;
|
|
14
|
+
this.body = body;
|
|
15
|
+
this.name = "DecidioHttpError";
|
|
16
|
+
this.retryable = !terminal;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Every tier the gate can record, weakest to strongest. Mirrors the server's enum exactly; an
|
|
2
|
+
* answer outside this list means this SDK version does not understand the record, and an answer
|
|
3
|
+
* we do not understand is never treated as evidence. */
|
|
4
|
+
export declare const EVIDENCE_TIERS: readonly ["pending", "execution_unconfirmed", "agent_asserted", "sensor_observed", "application_confirmed", "independently_reconciled"];
|
|
5
|
+
export type EvidenceTier = (typeof EVIDENCE_TIERS)[number];
|
|
6
|
+
export interface EvidenceOutcome {
|
|
7
|
+
/** Decidio accepted the report and named the tier it recorded. */
|
|
8
|
+
reportRecorded: boolean;
|
|
9
|
+
/** The tier Decidio recorded, verbatim. */
|
|
10
|
+
evidenceTier?: EvidenceTier;
|
|
11
|
+
/** The recorded tier attests a committed execution. */
|
|
12
|
+
executionConfirmed: boolean;
|
|
13
|
+
/** Present when the outcome is worth explaining (why a signed report did not confirm, or why
|
|
14
|
+
* a report was not recorded at all) — always actionable, never a shrug. */
|
|
15
|
+
diagnostic?: string;
|
|
16
|
+
/** Present when the gate reported on the SIGNED execution report: whether the attestation
|
|
17
|
+
* proof VERIFIED (C3, 0.1.12 — this field used to be read off the wire and discarded, so a
|
|
18
|
+
* failed attestation was invisible to every client door). */
|
|
19
|
+
executionReportVerified?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** The SERVER's rule for `application_confirmed`, restated here exactly as implemented so this
|
|
22
|
+
* explanation can never drift from it: the captured response must carry a RECORD ID
|
|
23
|
+
* (`id` / `recordId` / `Id`), and must not explicitly deny success (`success: false` /
|
|
24
|
+
* `ok: false`). An id with no success field is treated as a commit; a response with neither is
|
|
25
|
+
* honestly `execution_unconfirmed`. */
|
|
26
|
+
export declare function describeTierGap(capturedResponse: unknown): string;
|
|
27
|
+
/** Read the gate's confirm response and say precisely what happened. Fail-closed: an unknown or
|
|
28
|
+
* missing tier is NOT a recorded report. */
|
|
29
|
+
export declare function readConfirmResponse(body: unknown, opts: {
|
|
30
|
+
signed: boolean;
|
|
31
|
+
capturedResponse?: unknown;
|
|
32
|
+
journalled?: boolean;
|
|
33
|
+
}): EvidenceOutcome;
|
|
34
|
+
import { type RequesterIdentity } from "./signing.js";
|
|
35
|
+
/** Minimal identity a confirm body needs. */
|
|
36
|
+
export interface ReportIdentity {
|
|
37
|
+
agentId: string;
|
|
38
|
+
agentKey?: string;
|
|
39
|
+
agentDid?: string;
|
|
40
|
+
workspaceId?: string;
|
|
41
|
+
}
|
|
42
|
+
/** The ONE confirm request shape, shared by every door (inline, durable resume, adapters).
|
|
43
|
+
* Carries the identity proof (who is reporting) AND — when a key is configured — a signed,
|
|
44
|
+
* decision-bound ExecutionReport over the canonical response digest (what was reported). */
|
|
45
|
+
export declare function buildConfirmBody(cfg: ReportIdentity, decisionId: string, canonicalResult: unknown, requesterIdentity?: RequesterIdentity, source?: string): Record<string, unknown>;
|
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const EVIDENCE_TIERS = [
|
|
2
|
+
"pending",
|
|
3
|
+
"execution_unconfirmed",
|
|
4
|
+
"agent_asserted",
|
|
5
|
+
"sensor_observed",
|
|
6
|
+
"application_confirmed",
|
|
7
|
+
"independently_reconciled",
|
|
8
|
+
];
|
|
9
|
+
const CONFIRMING = ["sensor_observed", "application_confirmed", "independently_reconciled"];
|
|
10
|
+
export function describeTierGap(capturedResponse) {
|
|
11
|
+
const r = (capturedResponse ?? {});
|
|
12
|
+
const id = r.id ?? r.recordId ?? r.Id ?? null;
|
|
13
|
+
const denied = r.success === false || r.ok === false;
|
|
14
|
+
if (denied) {
|
|
15
|
+
return "your protected function reported success: false (or ok: false), so Decidio recorded the execution as UNCONFIRMED — that is the response saying the downstream write did not commit. If it did commit, return a truthy success flag.";
|
|
16
|
+
}
|
|
17
|
+
if (id == null) {
|
|
18
|
+
return "your protected function's return value carried no record id (`id`, `recordId` or `Id`), so Decidio has no evidence that the downstream write committed and honestly recorded the execution as UNCONFIRMED. Return the source system's identifiers (e.g. `{ id, success }` or `{ recordId, ok }`), or map them with the `evidence` option, to earn application_confirmed.";
|
|
19
|
+
}
|
|
20
|
+
return "the report was accepted but the gate recorded a weaker tier — check that this agent's registration and signing key still match (`npx @decidio/sdk doctor`).";
|
|
21
|
+
}
|
|
22
|
+
export function readConfirmResponse(body, opts) {
|
|
23
|
+
const retry = opts.journalled
|
|
24
|
+
? "re-send the report with `guard.resume.reconfirm(decisionId)` once connectivity or the service is healthy."
|
|
25
|
+
: "this path journalled no report, so there is nothing to re-send from here — record the outcome from your own system, or run the action through `guard.protect`'s durable mode, where reports ARE journalled and `reconfirm` can replay them.";
|
|
26
|
+
const b = body;
|
|
27
|
+
if (!b || b.ok !== true || typeof b.confirmation !== "string") {
|
|
28
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate answered 2xx but named no evidence tier — the execution report is NOT recorded. Your action DID run; ${retry}` };
|
|
29
|
+
}
|
|
30
|
+
const tier = b.confirmation;
|
|
31
|
+
if (!EVIDENCE_TIERS.includes(tier)) {
|
|
32
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the gate reported an evidence tier this SDK does not recognise (${JSON.stringify(tier)}) — refusing to treat it as evidence. Upgrade @decidio/sdk; your action DID run, and ${retry}` };
|
|
33
|
+
}
|
|
34
|
+
const executionConfirmed = CONFIRMING.includes(tier);
|
|
35
|
+
const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed };
|
|
36
|
+
if (!executionConfirmed && opts.signed)
|
|
37
|
+
outcome.diagnostic = describeTierGap(opts.capturedResponse);
|
|
38
|
+
else if (!executionConfirmed)
|
|
39
|
+
outcome.diagnostic = "no signing key is configured, so the gate capped this report at an unproven agent assertion. Run `npx @decidio/sdk init` to generate the agent's key and earn a confirming tier.";
|
|
40
|
+
const rpt = body?.executionReport;
|
|
41
|
+
if (rpt && typeof rpt === "object" && typeof rpt.verified === "boolean") {
|
|
42
|
+
outcome.executionReportVerified = rpt.verified;
|
|
43
|
+
if (!rpt.verified) {
|
|
44
|
+
const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
|
|
45
|
+
outcome.diagnostic = `${outcome.diagnostic ? outcome.diagnostic + " " : ""}The signed execution report did NOT verify${why} — the report was recorded without a proven attestation. Check the agent's key/DID/registration (npx @decidio/sdk doctor).`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return outcome;
|
|
49
|
+
}
|
|
50
|
+
import { signExecutionReport, resultDigest } from "./signing.js";
|
|
51
|
+
export function buildConfirmBody(cfg, decisionId, canonicalResult, requesterIdentity, source = "wrapper") {
|
|
52
|
+
const executionReport = (cfg.agentKey && cfg.agentDid)
|
|
53
|
+
? signExecutionReport(cfg.agentKey, {
|
|
54
|
+
decisionId, agentId: cfg.agentId, did: cfg.agentDid, workspaceId: cfg.workspaceId,
|
|
55
|
+
source, resultDigest: resultDigest(canonicalResult ?? null),
|
|
56
|
+
})
|
|
57
|
+
: undefined;
|
|
58
|
+
return {
|
|
59
|
+
decisionId,
|
|
60
|
+
capturedResponse: canonicalResult ?? null,
|
|
61
|
+
source,
|
|
62
|
+
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
63
|
+
...(executionReport ? { executionReport } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/guard.d.ts
CHANGED
|
@@ -21,7 +21,12 @@ export interface GuardFacadeConfig {
|
|
|
21
21
|
}
|
|
22
22
|
export interface ProtectOptions {
|
|
23
23
|
/** null/"generic" → the core gate; engine adapters (langgraph/openai/temporal/inngest)
|
|
24
|
-
* live in @decidio/sdk subpaths and take the engine handle.
|
|
24
|
+
* live in @decidio/sdk subpaths and take the engine handle.
|
|
25
|
+
*
|
|
26
|
+
* BETA: the engine adapters share the core's gate semantics (verdict allow-list, canonical
|
|
27
|
+
* arguments, transport rule and evidence handling are one implementation, conformance-tested
|
|
28
|
+
* across every door), but their ENGINE integration is verified against structural mocks, not
|
|
29
|
+
* live engines. Use the core path for production until that changes. */
|
|
25
30
|
adapter?: "generic" | "langgraph" | "openai" | "temporal" | "inngest";
|
|
26
31
|
/** "durable" (default) suspends for async approval; "blocking" polls (short approvals only). */
|
|
27
32
|
mode?: "durable" | "blocking";
|
|
@@ -37,16 +42,30 @@ export interface ProtectOptions {
|
|
|
37
42
|
pollTimeoutMs?: number;
|
|
38
43
|
/** Blocking-mode poll interval in ms (default 3000). */
|
|
39
44
|
pollIntervalMs?: number;
|
|
45
|
+
/** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
|
|
46
|
+
* its strongest wrapper tier (application_confirmed) only for a committed record id plus a
|
|
47
|
+
* success indication, so instead of forcing your API into magic field names:
|
|
48
|
+
* `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
|
|
49
|
+
evidence?: (result: any) => unknown;
|
|
40
50
|
}
|
|
41
51
|
export declare class Guard {
|
|
42
52
|
config: GuardFacadeConfig;
|
|
43
|
-
|
|
53
|
+
private _resume?;
|
|
44
54
|
constructor(config: GuardFacadeConfig, resume?: ResumeController);
|
|
55
|
+
/** The recovery surface — `guard.resume.recover()` / `.reconfirm(id)`. BUILT ON FIRST ACCESS,
|
|
56
|
+
* because the process that needs to recover is usually a COLD one that never made the original
|
|
57
|
+
* call, so nothing has lazily constructed the controller yet. As a plain field this read was
|
|
58
|
+
* `undefined` there and the documented recovery loop crashed on the exact path it exists for.
|
|
59
|
+
* Unconfigured still fails loud (naming DECIDIO_API_URL), never silently. */
|
|
60
|
+
get resume(): ResumeController;
|
|
61
|
+
/** Kept writable: hosts that construct their own controller (custom store, injected fetch)
|
|
62
|
+
* assign it here, and `new Guard(cfg, controller)` must stay equivalent to that assignment. */
|
|
63
|
+
set resume(r: ResumeController);
|
|
45
64
|
static fromEnv(): Guard;
|
|
46
65
|
/** Register a raw fn under its action name so a COLD process (one that never made the original
|
|
47
66
|
* call) can resume it. Call this at startup beside `guard.worker()` — or pass
|
|
48
67
|
* `{ action }` to `protect(...)`, which does it for you at wrap time. */
|
|
49
|
-
register(action: string, fn: (...a: any[]) => Promise<any
|
|
68
|
+
register(action: string, fn: (...a: any[]) => Promise<any>, evidence?: (result: any) => unknown): void;
|
|
50
69
|
/** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
|
|
51
70
|
resumeHandler(): import("http").RequestListener;
|
|
52
71
|
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|