@decidio/sdk 0.1.10 → 0.1.11
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 +125 -25
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +61 -7
- 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 +104 -0
- package/dist/cli.js +114 -18
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +18 -0
- package/dist/evidence.d.ts +41 -0
- package/dist/evidence.js +57 -0
- package/dist/guard.d.ts +21 -2
- package/dist/guard.js +16 -9
- package/dist/index.d.ts +13 -2
- package/dist/index.js +43 -26
- package/dist/inngest.js +6 -30
- package/dist/resume.d.ts +74 -16
- package/dist/resume.js +197 -47
- package/dist/signing.d.ts +56 -0
- package/dist/signing.js +84 -1
- package/openapi.yaml +47 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process";
|
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
9
|
import { generateKeypair } from "./signing.js";
|
|
10
|
-
import { withApproval, DecidioRateLimitError } from "./index.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,6 +60,11 @@ 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();
|
|
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.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
61
68
|
if (auth && /^http:[/][/]/i.test(API) && !/^http:[/][/](localhost|127.0.0.1|.::1.)([:/]|$)/i.test(API)) {
|
|
62
69
|
err(`✘ refusing to send credentials over plaintext http to ${API} — use https, or localhost for local dev. Nothing was sent.`);
|
|
63
70
|
process.exit(1);
|
|
@@ -137,6 +144,28 @@ function upsertEnv(updates) {
|
|
|
137
144
|
lines.push(`${k}=${v}`);
|
|
138
145
|
writeSecretFile(ENV_PATH, lines.join("\n") + "\n");
|
|
139
146
|
}
|
|
147
|
+
function isTrackedByGit(path) {
|
|
148
|
+
try {
|
|
149
|
+
execFileSync("git", ["ls-files", "--error-unmatch", path], { stdio: "ignore" });
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function assertNotTracked(path) {
|
|
157
|
+
if (!isTrackedByGit(path))
|
|
158
|
+
return true;
|
|
159
|
+
err(`✘ ${path} is ALREADY TRACKED by git — refusing to write the agent's private key into it.`);
|
|
160
|
+
err(` A .gitignore entry does not untrack a file: the next \`git add -A\` would commit`);
|
|
161
|
+
err(` DECIDIO_AGENT_KEY and DECIDIO_API_TOKEN into permanent history. Nothing was written.`);
|
|
162
|
+
err(` Fix it, then re-run:`);
|
|
163
|
+
err(` git rm --cached ${path} # stop tracking it (keeps your local copy)`);
|
|
164
|
+
err(` git commit -m "stop tracking ${path}"`);
|
|
165
|
+
err(` If it has ALREADY been committed with secrets in it, rotate them — history rewriting`);
|
|
166
|
+
err(` alone does not un-leak a key that was pushed.`);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
140
169
|
function ensureGitignore() {
|
|
141
170
|
const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
|
|
142
171
|
const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
|
|
@@ -144,7 +173,7 @@ function ensureGitignore() {
|
|
|
144
173
|
if (!missing.length)
|
|
145
174
|
return;
|
|
146
175
|
appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
|
|
147
|
-
log(`✔ added ${missing.join(" + ")} to .gitignore (
|
|
176
|
+
log(`✔ added ${missing.join(" + ")} to .gitignore (new secret files here will not be committed)`);
|
|
148
177
|
}
|
|
149
178
|
function ask(question, mute = false) {
|
|
150
179
|
return new Promise((resolve) => {
|
|
@@ -204,6 +233,8 @@ async function acquireSession(purpose) {
|
|
|
204
233
|
return null;
|
|
205
234
|
}
|
|
206
235
|
async function cmdInit(agentId, opts = {}) {
|
|
236
|
+
if (!assertNotTracked(ENV_PATH))
|
|
237
|
+
return 1;
|
|
207
238
|
err(`Registering agent "${agentId}" with Decidio at ${API}…`);
|
|
208
239
|
const existingEnv = readEnvFile();
|
|
209
240
|
const existingKey = existingEnv.DECIDIO_AGENT_KEY;
|
|
@@ -342,8 +373,20 @@ async function cmdDoctor() {
|
|
|
342
373
|
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
374
|
: "• 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
375
|
const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
345
|
-
if (signing)
|
|
346
|
-
|
|
376
|
+
if (signing) {
|
|
377
|
+
const storedDid = (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
|
|
378
|
+
let derived;
|
|
379
|
+
try {
|
|
380
|
+
derived = didFromPrivateKey((process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY));
|
|
381
|
+
}
|
|
382
|
+
catch { }
|
|
383
|
+
if (!derived)
|
|
384
|
+
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).");
|
|
385
|
+
else if (derived !== storedDid)
|
|
386
|
+
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.`);
|
|
387
|
+
else
|
|
388
|
+
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).");
|
|
389
|
+
}
|
|
347
390
|
else
|
|
348
391
|
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
392
|
const h = await api("/api/health", {}, "");
|
|
@@ -366,7 +409,7 @@ async function cmdDoctor() {
|
|
|
366
409
|
else if (days <= 14)
|
|
367
410
|
warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
|
|
368
411
|
else
|
|
369
|
-
log(`✔ agent token:
|
|
412
|
+
log(`✔ agent token: unexpired for another ${days} days (validity is decided by the server — see the token-scope check below)`);
|
|
370
413
|
}
|
|
371
414
|
}
|
|
372
415
|
catch { }
|
|
@@ -396,6 +439,11 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
|
396
439
|
sub = undefined;
|
|
397
440
|
if ((sub === "approve" || sub === "reject") && !id) {
|
|
398
441
|
err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
|
|
442
|
+
err(` npx @decidio/sdk approvals ${sub} <decisionId> --reason "text with -dashes"`);
|
|
443
|
+
return 2;
|
|
444
|
+
}
|
|
445
|
+
if ((sub === "approve" || sub === "reject") && (flags.json || flags.mine)) {
|
|
446
|
+
err(`✘ ${flags.json ? "--json" : "--mine"} applies to \`approvals\` (the list), not to ${sub} — refusing to ignore a flag you meant to matter.`);
|
|
399
447
|
return 2;
|
|
400
448
|
}
|
|
401
449
|
if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
|
|
@@ -422,8 +470,18 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
|
|
|
422
470
|
return 1;
|
|
423
471
|
}
|
|
424
472
|
log(`✔ ${sub === "approve" ? "approved" : "rejected"} ${id}${r.body?.resumeSignal ? ` — agent signalled (${r.body.resumeSignal.ok ? "ok" : r.body.resumeSignal.error})` : ""}`);
|
|
425
|
-
if (
|
|
426
|
-
log(`
|
|
473
|
+
if (reason) {
|
|
474
|
+
log(` reason sent verbatim: ${JSON.stringify(reason)}`);
|
|
475
|
+
log(` (it seals immutably; personal or sensitive values are tokenized at rest, so the`);
|
|
476
|
+
log(` stored text can differ — see exactly what was kept: npx @decidio/sdk receipt ${id})`);
|
|
477
|
+
}
|
|
478
|
+
if (sub === "approve") {
|
|
479
|
+
log(` What happens next depends on how the agent is waiting:`);
|
|
480
|
+
log(` • durable (the production mode): its worker or resume route picks this up and executes.`);
|
|
481
|
+
log(` • blocking, still running: it resumes now.`);
|
|
482
|
+
log(` • blocking, already timed out or exited: NOTHING executes — that process is gone, and this`);
|
|
483
|
+
log(` approval resolved the RECORD only. Re-run the action to request again.`);
|
|
484
|
+
}
|
|
427
485
|
else
|
|
428
486
|
log(` The agent's action will NOT run; the rejection seals as a verifiable record.`);
|
|
429
487
|
log(` Download the receipt: npx @decidio/sdk receipt ${id}`);
|
|
@@ -517,11 +575,13 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
517
575
|
if (conf?.status)
|
|
518
576
|
log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
|
|
519
577
|
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
520
|
-
log(` npx @decidio/sdk verify ${name}`);
|
|
521
578
|
if (issuer) {
|
|
522
|
-
log(` Your workspace's issuer DID (from the authenticated response — record it once, then pin it forever):`);
|
|
523
|
-
log(` ${issuer}`);
|
|
524
579
|
log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
|
|
580
|
+
log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
|
|
581
|
+
log(` response above — record it once, then pin it forever):`);
|
|
582
|
+
log(` ${issuer}`);
|
|
583
|
+
log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
|
|
584
|
+
log(` issued it; it says so in its output.`);
|
|
525
585
|
}
|
|
526
586
|
else {
|
|
527
587
|
log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
|
|
@@ -536,8 +596,8 @@ function cmdVerify(argsIn) {
|
|
|
536
596
|
const a = argsIn[i];
|
|
537
597
|
if (a === "--issuer") {
|
|
538
598
|
const v = argsIn[i + 1];
|
|
539
|
-
if (v === undefined || v.startsWith("-")) {
|
|
540
|
-
err("✘ --issuer needs a did:key value.");
|
|
599
|
+
if (v === undefined || v === "" || v.startsWith("-")) {
|
|
600
|
+
err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
|
|
541
601
|
return 2;
|
|
542
602
|
}
|
|
543
603
|
expectedIssuer = v;
|
|
@@ -609,6 +669,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
609
669
|
let envFile = ENV_PATH;
|
|
610
670
|
if (readEnvFile().DECIDIO_AGENT_KEY) {
|
|
611
671
|
envFile = ".env.quickstart";
|
|
672
|
+
if (!assertNotTracked(envFile))
|
|
673
|
+
return 1;
|
|
612
674
|
ensureGitignore();
|
|
613
675
|
if (existsSync(envFile)) {
|
|
614
676
|
const prev = readFileSync(envFile, "utf8");
|
|
@@ -623,6 +685,8 @@ async function cmdQuickstart(opts = {}) {
|
|
|
623
685
|
log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
|
|
624
686
|
}
|
|
625
687
|
else {
|
|
688
|
+
if (!assertNotTracked(ENV_PATH))
|
|
689
|
+
return 1;
|
|
626
690
|
ensureGitignore();
|
|
627
691
|
upsertEnv(envOut);
|
|
628
692
|
}
|
|
@@ -762,8 +826,12 @@ async function quickstartCleanup(session, envFile) {
|
|
|
762
826
|
log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
|
|
763
827
|
else if (rev.status >= 200 && rev.status < 300)
|
|
764
828
|
log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
|
|
765
|
-
else
|
|
766
|
-
|
|
829
|
+
else {
|
|
830
|
+
err(`✘ token revoke returned ${rev.status} — the tour's token is STILL LIVE (it would expire on its own within 7 days).`);
|
|
831
|
+
err(` Nothing was deleted: ${envFile} still holds the jti so you can retry. Run \`npx @decidio/sdk quickstart cleanup\` again, or revoke it directly:`);
|
|
832
|
+
err(` npx @decidio/sdk tokens revoke ${env.DECIDIO_TOKEN_JTI}`);
|
|
833
|
+
return 1;
|
|
834
|
+
}
|
|
767
835
|
}
|
|
768
836
|
else {
|
|
769
837
|
log(` ⚠ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — revoke by hand: npx @decidio/sdk tokens list, then tokens revoke <jti>. It expires on its own within 7 days.`);
|
|
@@ -916,6 +984,10 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
916
984
|
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
917
985
|
npx @decidio/sdk approvals approve <id> [reason]
|
|
918
986
|
npx @decidio/sdk approvals reject <id> [reason]
|
|
987
|
+
the reason is FREE TEXT and is sealed verbatim. If it
|
|
988
|
+
starts with a dash, put it after -- or use --reason:
|
|
989
|
+
... approve <id> -- "-50% cut, per the board"
|
|
990
|
+
... approve <id> --reason "-50% cut, per the board"
|
|
919
991
|
npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
|
|
920
992
|
npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE
|
|
921
993
|
npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
|
|
@@ -949,7 +1021,7 @@ async function main(argv) {
|
|
|
949
1021
|
quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
|
|
950
1022
|
init: { flags: ["--unique", "--force"], valued: ["--expires-days"] },
|
|
951
1023
|
doctor: { flags: [] },
|
|
952
|
-
approvals: { flags: ["--json", "--mine"] },
|
|
1024
|
+
approvals: { flags: ["--json", "--mine"], valued: ["--reason"] },
|
|
953
1025
|
receipt: { flags: ["--json"] },
|
|
954
1026
|
verify: { flags: [], valued: ["--issuer"] },
|
|
955
1027
|
tokens: { flags: ["--mine"] },
|
|
@@ -961,6 +1033,10 @@ async function main(argv) {
|
|
|
961
1033
|
const positionals = [];
|
|
962
1034
|
for (let i = 0; i < argsIn.length; i++) {
|
|
963
1035
|
const a = argsIn[i];
|
|
1036
|
+
if (a === "--") {
|
|
1037
|
+
positionals.push(...argsIn.slice(i + 1));
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
964
1040
|
if (!a.startsWith("--")) {
|
|
965
1041
|
positionals.push(a);
|
|
966
1042
|
continue;
|
|
@@ -1028,8 +1104,28 @@ async function main(argv) {
|
|
|
1028
1104
|
}
|
|
1029
1105
|
case "doctor": return cmdDoctor();
|
|
1030
1106
|
case "approvals": {
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1107
|
+
const dashdash = argsIn.indexOf("--");
|
|
1108
|
+
const head = dashdash >= 0 ? argsIn.slice(0, dashdash) : argsIn;
|
|
1109
|
+
const literal = dashdash >= 0 ? argsIn.slice(dashdash + 1) : [];
|
|
1110
|
+
const rIdx = head.indexOf("--reason");
|
|
1111
|
+
const flagless = head.filter((x, i) => !x.startsWith("-") && !(rIdx >= 0 && i === rIdx + 1));
|
|
1112
|
+
const sub = flagless[0];
|
|
1113
|
+
const id = flagless[1];
|
|
1114
|
+
let reason;
|
|
1115
|
+
if (rIdx >= 0)
|
|
1116
|
+
reason = head[rIdx + 1];
|
|
1117
|
+
else if (literal.length)
|
|
1118
|
+
reason = literal.join(" ");
|
|
1119
|
+
else if (sub && id) {
|
|
1120
|
+
const after = head.indexOf(id) + 1;
|
|
1121
|
+
const rest = head.slice(after).filter((x) => x !== "--json" && x !== "--mine");
|
|
1122
|
+
reason = rest.length ? rest.join(" ") : undefined;
|
|
1123
|
+
}
|
|
1124
|
+
if (rIdx >= 0 && (reason === undefined || reason.startsWith("-"))) {
|
|
1125
|
+
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"`);
|
|
1126
|
+
return 2;
|
|
1127
|
+
}
|
|
1128
|
+
return cmdApprovals(sub, id, reason, { json: head.includes("--json"), mine: head.includes("--mine") });
|
|
1033
1129
|
}
|
|
1034
1130
|
case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
|
|
1035
1131
|
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,41 @@
|
|
|
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
|
+
}
|
|
17
|
+
/** The SERVER's rule for `application_confirmed`, restated here exactly as implemented so this
|
|
18
|
+
* explanation can never drift from it: the captured response must carry a RECORD ID
|
|
19
|
+
* (`id` / `recordId` / `Id`), and must not explicitly deny success (`success: false` /
|
|
20
|
+
* `ok: false`). An id with no success field is treated as a commit; a response with neither is
|
|
21
|
+
* honestly `execution_unconfirmed`. */
|
|
22
|
+
export declare function describeTierGap(capturedResponse: unknown): string;
|
|
23
|
+
/** Read the gate's confirm response and say precisely what happened. Fail-closed: an unknown or
|
|
24
|
+
* missing tier is NOT a recorded report. */
|
|
25
|
+
export declare function readConfirmResponse(body: unknown, opts: {
|
|
26
|
+
signed: boolean;
|
|
27
|
+
capturedResponse?: unknown;
|
|
28
|
+
journalled?: boolean;
|
|
29
|
+
}): EvidenceOutcome;
|
|
30
|
+
import { type RequesterIdentity } from "./signing.js";
|
|
31
|
+
/** Minimal identity a confirm body needs. */
|
|
32
|
+
export interface ReportIdentity {
|
|
33
|
+
agentId: string;
|
|
34
|
+
agentKey?: string;
|
|
35
|
+
agentDid?: string;
|
|
36
|
+
workspaceId?: string;
|
|
37
|
+
}
|
|
38
|
+
/** The ONE confirm request shape, shared by every door (inline, durable resume, adapters).
|
|
39
|
+
* Carries the identity proof (who is reporting) AND — when a key is configured — a signed,
|
|
40
|
+
* decision-bound ExecutionReport over the canonical response digest (what was reported). */
|
|
41
|
+
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,57 @@
|
|
|
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
|
+
return outcome;
|
|
41
|
+
}
|
|
42
|
+
import { signExecutionReport, resultDigest } from "./signing.js";
|
|
43
|
+
export function buildConfirmBody(cfg, decisionId, canonicalResult, requesterIdentity, source = "wrapper") {
|
|
44
|
+
const executionReport = (cfg.agentKey && cfg.agentDid)
|
|
45
|
+
? signExecutionReport(cfg.agentKey, {
|
|
46
|
+
decisionId, agentId: cfg.agentId, did: cfg.agentDid, workspaceId: cfg.workspaceId,
|
|
47
|
+
source, resultDigest: resultDigest(canonicalResult ?? null),
|
|
48
|
+
})
|
|
49
|
+
: undefined;
|
|
50
|
+
return {
|
|
51
|
+
decisionId,
|
|
52
|
+
capturedResponse: canonicalResult ?? null,
|
|
53
|
+
source,
|
|
54
|
+
...(requesterIdentity ? { requesterIdentity } : {}),
|
|
55
|
+
...(executionReport ? { executionReport } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
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,11 +42,25 @@ 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
|
package/dist/guard.js
CHANGED
|
@@ -2,11 +2,13 @@ import { withApproval } from "./index.js";
|
|
|
2
2
|
import { createDecidioResume, FilePendingStore } from "./resume.js";
|
|
3
3
|
export class Guard {
|
|
4
4
|
config;
|
|
5
|
-
|
|
5
|
+
_resume;
|
|
6
6
|
constructor(config, resume) {
|
|
7
7
|
this.config = config;
|
|
8
|
-
this.
|
|
8
|
+
this._resume = resume;
|
|
9
9
|
}
|
|
10
|
+
get resume() { return this.ensureResume(); }
|
|
11
|
+
set resume(r) { this._resume = r; }
|
|
10
12
|
static fromEnv() {
|
|
11
13
|
return new Guard({
|
|
12
14
|
apiUrl: process.env.DECIDIO_API_URL ?? "",
|
|
@@ -30,16 +32,16 @@ export class Guard {
|
|
|
30
32
|
}
|
|
31
33
|
ensureResume() {
|
|
32
34
|
this.assertConfigured();
|
|
33
|
-
if (!this.
|
|
35
|
+
if (!this._resume) {
|
|
34
36
|
const c = this.config;
|
|
35
|
-
this.
|
|
37
|
+
this._resume = createDecidioResume({
|
|
36
38
|
apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
|
|
37
39
|
resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
|
|
38
40
|
agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
|
|
39
41
|
fetchImpl: c.fetchImpl,
|
|
40
42
|
});
|
|
41
43
|
}
|
|
42
|
-
return this.
|
|
44
|
+
return this._resume;
|
|
43
45
|
}
|
|
44
46
|
assertConfigured() {
|
|
45
47
|
if (!this.config.apiUrl)
|
|
@@ -61,14 +63,19 @@ export class Guard {
|
|
|
61
63
|
pollTimeoutMs: opts?.pollTimeoutMs, pollIntervalMs: opts?.pollIntervalMs,
|
|
62
64
|
};
|
|
63
65
|
if ((opts?.mode ?? "durable") === "blocking") {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const ceilingMs = opts?.pollTimeoutMs ?? base.pollTimeoutMs ?? 600_000;
|
|
67
|
+
console.warn(`[decidio] WARNING: blocking wait — this call gives up after ${Math.round(ceilingMs / 1000)}s ` +
|
|
68
|
+
`(pollTimeoutMs), and when it does, the decision stays OPEN in Decidio while this process stops waiting: ` +
|
|
69
|
+
`a later approval cannot resume it. Only safe for short, watched approvals — omit mode:"blocking" for ` +
|
|
70
|
+
`durable async resume on real (minutes-to-days) approvals.`);
|
|
71
|
+
return withApproval(fn, describe, base, opts?.evidence ? { evidence: opts.evidence } : undefined);
|
|
67
72
|
}
|
|
68
73
|
const resume = this.ensureResume();
|
|
69
74
|
if (opts?.action)
|
|
70
75
|
resume.register(opts.action, fn);
|
|
71
|
-
|
|
76
|
+
if (opts?.evidence)
|
|
77
|
+
resume.evidence = opts.evidence;
|
|
78
|
+
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, opts?.evidence ? { evidence: opts.evidence } : undefined);
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
export const guard = Guard.fromEnv();
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,12 @@ export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDeci
|
|
|
3
3
|
export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
|
|
4
4
|
export { guard, Guard } from "./guard.js";
|
|
5
5
|
export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
|
|
6
|
-
export {
|
|
6
|
+
export { DecidioHttpError } from "./errors.js";
|
|
7
|
+
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
|
|
8
|
+
export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
9
|
+
export type { JsonValue, JsonPrimitive } from "./canonical.js";
|
|
10
|
+
export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
|
|
11
|
+
export type { EvidenceOutcome, EvidenceTier } from "./evidence.js";
|
|
7
12
|
export type { RequesterIdentity } from "./signing.js";
|
|
8
13
|
export interface GuardConfig {
|
|
9
14
|
/** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
|
|
@@ -102,4 +107,10 @@ export declare class DecidioNetworkError extends Error {
|
|
|
102
107
|
/** `<appUrl>/d/<decisionId>` — the deep link that opens this decision directly, on the web app or
|
|
103
108
|
* the phone build. Null when DECIDIO_APP_URL is not configured, never a half-built URL. */
|
|
104
109
|
export declare function decisionUrl(appUrl: string | undefined, decisionId: string): string | null;
|
|
105
|
-
export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig
|
|
110
|
+
export declare function withApproval<A extends any[], R>(fn: (...args: A) => Promise<R>, describe: (...args: A) => ApprovalContext, config: GuardConfig, opts?: {
|
|
111
|
+
/** Map YOUR function's natural return value to the evidence Decidio records. The gate awards
|
|
112
|
+
* its strongest wrapper tier (application_confirmed) only for a committed record id plus a
|
|
113
|
+
* success indication — this lets a real API keep its own shape instead of being forced into
|
|
114
|
+
* magic field names: `evidence: (r) => ({ recordId: r.transactionId, success: r.status === "committed" })`. */
|
|
115
|
+
evidence?: (result: R) => unknown;
|
|
116
|
+
}): (...args: A) => Promise<R>;
|