@decidio/sdk 0.1.11 → 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 +1 -0
- package/dist/adapters/_gate.js +18 -5
- package/dist/canonical.js +26 -2
- package/dist/cli.js +73 -17
- package/dist/evidence.d.ts +4 -0
- package/dist/evidence.js +8 -0
- package/dist/guard.d.ts +1 -1
- package/dist/guard.js +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +20 -8
- package/dist/resume.d.ts +4 -1
- package/dist/resume.js +46 -25
- package/dist/signing.d.ts +8 -0
- package/dist/signing.js +18 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -413,6 +413,7 @@ manager for production.
|
|
|
413
413
|
the fix. Anything else is transient, and retrying is safe because a retry creates a *new* request
|
|
414
414
|
and can never double-execute.
|
|
415
415
|
- `DecidioNetworkError` — the gate was unreachable. Nothing executed locally.
|
|
416
|
+
- `DecidioUnsafeArgumentsError` — your call arguments cannot survive JSON with their meaning intact, so nothing was routed and nothing ran. The message names the field and the fix. Most often: `NaN`/`Infinity`, `-0`, a `Date`, `Map` or class instance, a `BigInt`, or a sparse array — anything whose serialized form would differ from the value the approval and signature cover. Pass the already-serialized form.
|
|
416
417
|
|
|
417
418
|
```js
|
|
418
419
|
try { await payInvoice(inv); }
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, canonicalize, readConfirmResponse, buildConfirmBody } from "../index.js";
|
|
2
|
+
import { DecidioBlockedError, DecidioRejectedError, DecidioHttpError, signBoundProof, signConfirm, assertSafeTransport, carriesCredential, canonicalize, readConfirmResponse, buildConfirmBody } from "../index.js";
|
|
3
3
|
export const AUTHORIZE_VERDICTS = ["proceed", "route", "block"];
|
|
4
4
|
export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
5
5
|
const a = auth;
|
|
@@ -7,11 +7,14 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
|
7
7
|
if (!a || !AUTHORIZE_VERDICTS.includes(a.decision)) {
|
|
8
8
|
throw new DecidioBlockedError(`unrecognised authorization verdict ${JSON.stringify(a?.decision ?? null)} — refusing to execute. The gate answers ${AUTHORIZE_VERDICTS.join(" | ")}; anything else means this SDK version does not understand the decision (upgrade @decidio/sdk), and an answer you do not understand is never permission.`, decisionId);
|
|
9
9
|
}
|
|
10
|
+
if (typeof a.decisionId !== "string" || !a.decisionId) {
|
|
11
|
+
throw new DecidioBlockedError(`the gate answered "${a.decision}" with no usable decisionId (${JSON.stringify(a.decisionId ?? null)}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, decisionId);
|
|
12
|
+
}
|
|
10
13
|
return a;
|
|
11
14
|
}
|
|
12
15
|
export async function gateAuthorize(config, ctx) {
|
|
13
16
|
canonicalize(ctx, "describe(...)");
|
|
14
|
-
assertSafeTransport(config.apiUrl,
|
|
17
|
+
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
15
18
|
const f = config.fetchImpl ?? fetch;
|
|
16
19
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
|
17
20
|
const requesterIdentity = (config.agentKey && config.agentDid)
|
|
@@ -40,17 +43,25 @@ export async function gateAuthorize(config, ctx) {
|
|
|
40
43
|
export async function gateConfirm(config, decisionId, result) {
|
|
41
44
|
const f = config.fetchImpl ?? fetch;
|
|
42
45
|
try {
|
|
43
|
-
assertSafeTransport(config.apiUrl,
|
|
46
|
+
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
44
47
|
}
|
|
45
48
|
catch (e) {
|
|
46
49
|
console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
|
|
47
50
|
return;
|
|
48
51
|
}
|
|
52
|
+
let reported = null;
|
|
53
|
+
let evidenceProblem;
|
|
54
|
+
try {
|
|
55
|
+
reported = canonicalize(result ?? null, "result");
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
evidenceProblem = String(e?.message ?? e);
|
|
59
|
+
console.warn(`[decidio] ${decisionId}: the action SUCCEEDED but its result could not be reported as evidence (${evidenceProblem}). Reporting an empty captured response so the execution is still recorded — map the return value with the \`evidence\` option to earn a confirming tier. The action was NOT re-run.`);
|
|
60
|
+
}
|
|
49
61
|
try {
|
|
50
62
|
const requesterIdentity = signConfirm(decisionId, config);
|
|
51
63
|
if (!requesterIdentity)
|
|
52
64
|
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.`);
|
|
53
|
-
const reported = canonicalize(result ?? null, "result");
|
|
54
65
|
const res = await f(config.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
55
66
|
method: "POST",
|
|
56
67
|
headers: { "content-type": "application/json", ...(config.apiToken ? { authorization: `Bearer ${config.apiToken}` } : {}) },
|
|
@@ -63,8 +74,10 @@ export async function gateConfirm(config, decisionId, result) {
|
|
|
63
74
|
const ev = readConfirmResponse(await res.json().catch(() => null), { signed: !!requesterIdentity, capturedResponse: reported });
|
|
64
75
|
if (!ev.reportRecorded)
|
|
65
76
|
console.warn(`[decidio] ${decisionId}: ${ev.diagnostic}`);
|
|
66
|
-
else if (!ev.executionConfirmed)
|
|
77
|
+
else if (!ev.executionConfirmed || ev.executionReportVerified === false)
|
|
67
78
|
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
|
|
79
|
+
if (evidenceProblem)
|
|
80
|
+
console.warn(`[decidio] ${decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
|
|
68
81
|
}
|
|
69
82
|
catch (e) {
|
|
70
83
|
console.warn(`[decidio] confirm failed (action succeeded): ${e.message}`);
|
package/dist/canonical.js
CHANGED
|
@@ -10,6 +10,22 @@ const NOT_ROUTED = "The decision was NOT routed; nothing reached Decidio.";
|
|
|
10
10
|
function refuse(path, what, fix) {
|
|
11
11
|
throw new DecidioUnsafeArgumentsError(`[decidio] ${path} ${what} — the approval payload must be plain JSON so that what a human approves is exactly what executes; ${fix} ${NOT_ROUTED}`);
|
|
12
12
|
}
|
|
13
|
+
function assertNoLoneSurrogate(s, p) {
|
|
14
|
+
for (let i = 0; i < s.length; i++) {
|
|
15
|
+
const c = s.charCodeAt(i);
|
|
16
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
17
|
+
const n = s.charCodeAt(i + 1);
|
|
18
|
+
if (n >= 0xdc00 && n <= 0xdfff) {
|
|
19
|
+
i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
refuse(p, `contains an unpaired surrogate (U+${c.toString(16).toUpperCase()})`, "it cannot be encoded as UTF-8, so no wire or seal can carry it — repair the string (it is usually a split emoji or truncated UTF-16).");
|
|
23
|
+
}
|
|
24
|
+
else if (c >= 0xdc00 && c <= 0xdfff) {
|
|
25
|
+
refuse(p, `contains an unpaired surrogate (U+${c.toString(16).toUpperCase()})`, "it cannot be encoded as UTF-8, so no wire or seal can carry it — repair the string (it is usually a split emoji or truncated UTF-16).");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
13
29
|
export function canonicalize(value, path = "args") {
|
|
14
30
|
return walk(value, path, new Set());
|
|
15
31
|
}
|
|
@@ -17,8 +33,12 @@ function walk(v, p, seen) {
|
|
|
17
33
|
if (v === null)
|
|
18
34
|
return null;
|
|
19
35
|
const t = typeof v;
|
|
20
|
-
if (t === "boolean"
|
|
36
|
+
if (t === "boolean")
|
|
37
|
+
return v;
|
|
38
|
+
if (t === "string") {
|
|
39
|
+
assertNoLoneSurrogate(v, p);
|
|
21
40
|
return v;
|
|
41
|
+
}
|
|
22
42
|
if (t === "number") {
|
|
23
43
|
const n = v;
|
|
24
44
|
if (!Number.isFinite(n))
|
|
@@ -72,7 +92,11 @@ function walk(v, p, seen) {
|
|
|
72
92
|
refuse(`${p}.${key}`, "is a getter/setter", "an accessor is evaluated at serialization time, so the approved value could differ from the executed one — pass a plain data property.");
|
|
73
93
|
if (!d.enumerable)
|
|
74
94
|
refuse(`${p}.${key}`, "is a non-enumerable property", "JSON drops it, so the approver would never see it — make it enumerable or remove it.");
|
|
75
|
-
|
|
95
|
+
assertNoLoneSurrogate(key, `${p}.${key}`);
|
|
96
|
+
Object.defineProperty(out, key, {
|
|
97
|
+
value: walk(d.value, `${p}.${key}`, seen),
|
|
98
|
+
writable: true, enumerable: true, configurable: true,
|
|
99
|
+
});
|
|
76
100
|
}
|
|
77
101
|
return out;
|
|
78
102
|
}
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ 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";
|
|
9
|
+
import { generateKeypair, assertSafeTransport } from "./signing.js";
|
|
10
10
|
import { withApproval, DecidioRateLimitError, didFromPrivateKey } from "./index.js";
|
|
11
11
|
import { verifyReceipt, runVerify } from "@decidio/verify";
|
|
12
12
|
const VERSION = (() => {
|
|
@@ -65,9 +65,14 @@ async function api(path, init = {}, bearer) {
|
|
|
65
65
|
err(` Fix the file's permissions, or set DECIDIO_API_URL explicitly for this command. Nothing was sent.`);
|
|
66
66
|
process.exit(1);
|
|
67
67
|
}
|
|
68
|
-
if (auth
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
}
|
|
71
76
|
}
|
|
72
77
|
let res;
|
|
73
78
|
try {
|
|
@@ -189,7 +194,10 @@ function ask(question, mute = false) {
|
|
|
189
194
|
});
|
|
190
195
|
}
|
|
191
196
|
async function acquireSession(purpose) {
|
|
192
|
-
|
|
197
|
+
try {
|
|
198
|
+
assertSafeTransport(API, true);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
193
201
|
err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
|
|
194
202
|
err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
|
|
195
203
|
return null;
|
|
@@ -404,12 +412,13 @@ async function cmdDoctor() {
|
|
|
404
412
|
const msLeft = payload.exp * 1000 - Date.now();
|
|
405
413
|
const days = Math.floor((msLeft + 60_000) / 86_400_000);
|
|
406
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);
|
|
407
416
|
if (msLeft < 0)
|
|
408
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.`);
|
|
409
418
|
else if (days <= 14)
|
|
410
419
|
warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
|
|
411
420
|
else
|
|
412
|
-
log(`✔ agent token: unexpired for another ${
|
|
421
|
+
log(`✔ agent token: unexpired for another ${daysRounded} days (validity is decided by the server — see the token-scope check below)`);
|
|
413
422
|
}
|
|
414
423
|
}
|
|
415
424
|
catch { }
|
|
@@ -647,12 +656,27 @@ async function cmdQuickstart(opts = {}) {
|
|
|
647
656
|
const session = await acquireSession("The quickstart");
|
|
648
657
|
if (!session)
|
|
649
658
|
return 1;
|
|
650
|
-
|
|
651
|
-
log(`① Registering agent "${agentId}"
|
|
659
|
+
let agentId = `quickstart-${randomBytes(5).toString("hex")}`;
|
|
660
|
+
log(`① Registering agent "${agentId}"…`);
|
|
652
661
|
const { did, privateKeyBase64 } = generateKeypair();
|
|
653
|
-
|
|
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
|
+
}
|
|
654
669
|
if (!(reg.status >= 200 && reg.status < 300)) {
|
|
655
|
-
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.`);
|
|
656
680
|
return 1;
|
|
657
681
|
}
|
|
658
682
|
const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
|
|
@@ -749,7 +773,10 @@ async function cmdQuickstart(opts = {}) {
|
|
|
749
773
|
log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
|
|
750
774
|
log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
|
|
751
775
|
log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
|
|
752
|
-
|
|
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);
|
|
753
780
|
}
|
|
754
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);
|
|
755
782
|
if (!(ap.status >= 200 && ap.status < 300)) {
|
|
@@ -777,7 +804,13 @@ async function cmdQuickstart(opts = {}) {
|
|
|
777
804
|
writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
|
|
778
805
|
const issuer = rec.body?.issuer;
|
|
779
806
|
const v = verifyReceipt(rec.body.credential, issuer);
|
|
780
|
-
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
|
+
}
|
|
781
814
|
if (issuer)
|
|
782
815
|
log(` Keep that DID: it is your trust anchor — npx @decidio/sdk verify --issuer ${issuer} <receipt.json>`);
|
|
783
816
|
const detail = await api(`/api/records/${encodeURIComponent(decisionId)}`, {}, session);
|
|
@@ -834,7 +867,12 @@ async function quickstartCleanup(session, envFile) {
|
|
|
834
867
|
}
|
|
835
868
|
}
|
|
836
869
|
else {
|
|
837
|
-
|
|
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;
|
|
838
876
|
}
|
|
839
877
|
if (existsSync(envFile)) {
|
|
840
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"]);
|
|
@@ -984,8 +1022,10 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
984
1022
|
npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
|
|
985
1023
|
npx @decidio/sdk approvals approve <id> [reason]
|
|
986
1024
|
npx @decidio/sdk approvals reject <id> [reason]
|
|
987
|
-
the reason is FREE TEXT
|
|
988
|
-
|
|
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:
|
|
989
1029
|
... approve <id> -- "-50% cut, per the board"
|
|
990
1030
|
... approve <id> --reason "-50% cut, per the board"
|
|
991
1031
|
npx @decidio/sdk receipt <id> download a sealed decision's Authority Receipt (.json)
|
|
@@ -996,7 +1036,9 @@ const USAGE = `decidio — the Decidio SDK command line
|
|
|
996
1036
|
Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
|
|
997
1037
|
DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
|
|
998
1038
|
fully offline — it touches no network and reads no .env.
|
|
999
|
-
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).
|
|
1000
1042
|
Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
|
|
1001
1043
|
pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
|
|
1002
1044
|
Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
|
|
@@ -1037,6 +1079,13 @@ async function main(argv) {
|
|
|
1037
1079
|
positionals.push(...argsIn.slice(i + 1));
|
|
1038
1080
|
break;
|
|
1039
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
|
+
}
|
|
1040
1089
|
if (!a.startsWith("--")) {
|
|
1041
1090
|
positionals.push(a);
|
|
1042
1091
|
continue;
|
|
@@ -1108,12 +1157,15 @@ async function main(argv) {
|
|
|
1108
1157
|
const head = dashdash >= 0 ? argsIn.slice(0, dashdash) : argsIn;
|
|
1109
1158
|
const literal = dashdash >= 0 ? argsIn.slice(dashdash + 1) : [];
|
|
1110
1159
|
const rIdx = head.indexOf("--reason");
|
|
1160
|
+
const rEq = head.find((x) => x.startsWith("--reason="));
|
|
1111
1161
|
const flagless = head.filter((x, i) => !x.startsWith("-") && !(rIdx >= 0 && i === rIdx + 1));
|
|
1112
1162
|
const sub = flagless[0];
|
|
1113
1163
|
const id = flagless[1];
|
|
1114
1164
|
let reason;
|
|
1115
1165
|
if (rIdx >= 0)
|
|
1116
1166
|
reason = head[rIdx + 1];
|
|
1167
|
+
else if (rEq !== undefined)
|
|
1168
|
+
reason = rEq.slice("--reason=".length);
|
|
1117
1169
|
else if (literal.length)
|
|
1118
1170
|
reason = literal.join(" ");
|
|
1119
1171
|
else if (sub && id) {
|
|
@@ -1121,10 +1173,14 @@ async function main(argv) {
|
|
|
1121
1173
|
const rest = head.slice(after).filter((x) => x !== "--json" && x !== "--mine");
|
|
1122
1174
|
reason = rest.length ? rest.join(" ") : undefined;
|
|
1123
1175
|
}
|
|
1124
|
-
if (rIdx >= 0 && (reason === undefined || reason.startsWith("
|
|
1176
|
+
if ((rIdx >= 0 || rEq !== undefined) && (reason === undefined || reason === "" || (rIdx >= 0 && reason.startsWith("--")))) {
|
|
1125
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"`);
|
|
1126
1178
|
return 2;
|
|
1127
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
|
+
}
|
|
1128
1184
|
return cmdApprovals(sub, id, reason, { json: head.includes("--json"), mine: head.includes("--mine") });
|
|
1129
1185
|
}
|
|
1130
1186
|
case "receipt": return cmdReceipt(argsIn.find((x) => !x.startsWith("-")), argsIn.includes("--json"));
|
package/dist/evidence.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface EvidenceOutcome {
|
|
|
13
13
|
/** Present when the outcome is worth explaining (why a signed report did not confirm, or why
|
|
14
14
|
* a report was not recorded at all) — always actionable, never a shrug. */
|
|
15
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;
|
|
16
20
|
}
|
|
17
21
|
/** The SERVER's rule for `application_confirmed`, restated here exactly as implemented so this
|
|
18
22
|
* explanation can never drift from it: the captured response must carry a RECORD ID
|
package/dist/evidence.js
CHANGED
|
@@ -37,6 +37,14 @@ export function readConfirmResponse(body, opts) {
|
|
|
37
37
|
outcome.diagnostic = describeTierGap(opts.capturedResponse);
|
|
38
38
|
else if (!executionConfirmed)
|
|
39
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
|
+
}
|
|
40
48
|
return outcome;
|
|
41
49
|
}
|
|
42
50
|
import { signExecutionReport, resultDigest } from "./signing.js";
|
package/dist/guard.d.ts
CHANGED
|
@@ -65,7 +65,7 @@ export declare class Guard {
|
|
|
65
65
|
/** Register a raw fn under its action name so a COLD process (one that never made the original
|
|
66
66
|
* call) can resume it. Call this at startup beside `guard.worker()` — or pass
|
|
67
67
|
* `{ action }` to `protect(...)`, which does it for you at wrap time. */
|
|
68
|
-
register(action: string, fn: (...a: any[]) => Promise<any
|
|
68
|
+
register(action: string, fn: (...a: any[]) => Promise<any>, evidence?: (result: any) => unknown): void;
|
|
69
69
|
/** Mount on a node/Express/Fastify route: `app.post("/decidio/resume", guard.resumeHandler())`. */
|
|
70
70
|
resumeHandler(): import("http").RequestListener;
|
|
71
71
|
/** Mount on a Next.js/Hono/CF/Bun route: `export const POST = (req) => guard.resumeFetchHandler()(req)`. */
|
package/dist/guard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withApproval } from "./index.js";
|
|
2
2
|
import { createDecidioResume, FilePendingStore } from "./resume.js";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
export class Guard {
|
|
4
5
|
config;
|
|
5
6
|
_resume;
|
|
@@ -24,7 +25,7 @@ export class Guard {
|
|
|
24
25
|
appUrl: process.env.DECIDIO_APP_URL,
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
|
-
register(action, fn) { this.ensureResume().register(action, fn); }
|
|
28
|
+
register(action, fn, evidence) { this.ensureResume().register(action, fn, evidence); }
|
|
28
29
|
resumeHandler() { return this.ensureResume().webhookHandler(); }
|
|
29
30
|
resumeFetchHandler() { return this.ensureResume().fetchHandler(); }
|
|
30
31
|
worker(opts) {
|
|
@@ -37,6 +38,7 @@ export class Guard {
|
|
|
37
38
|
this._resume = createDecidioResume({
|
|
38
39
|
apiUrl: c.apiUrl, apiToken: c.apiToken, webhookSecret: c.webhookSecret,
|
|
39
40
|
resumeUrl: c.resumeUrl, store: new FilePendingStore(c.pendingDir ?? ".decidio-pending"),
|
|
41
|
+
reportDir: join(c.pendingDir ?? ".decidio-pending", "reports"),
|
|
40
42
|
agentId: c.agentId, agentKey: c.agentKey, agentDid: c.agentDid, workspaceId: c.workspaceId,
|
|
41
43
|
fetchImpl: c.fetchImpl,
|
|
42
44
|
});
|
|
@@ -72,9 +74,7 @@ export class Guard {
|
|
|
72
74
|
}
|
|
73
75
|
const resume = this.ensureResume();
|
|
74
76
|
if (opts?.action)
|
|
75
|
-
resume.register(opts.action, fn);
|
|
76
|
-
if (opts?.evidence)
|
|
77
|
-
resume.evidence = opts.evidence;
|
|
77
|
+
resume.register(opts.action, fn, opts?.evidence);
|
|
78
78
|
return withApproval(fn, describe, { ...base, resumeUrl: c.resumeUrl, resume }, opts?.evidence ? { evidence: opts.evidence } : undefined);
|
|
79
79
|
}
|
|
80
80
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } fro
|
|
|
4
4
|
export { guard, Guard } from "./guard.js";
|
|
5
5
|
export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
|
|
6
6
|
export { DecidioHttpError } from "./errors.js";
|
|
7
|
-
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
|
|
7
|
+
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
8
8
|
export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
9
9
|
export type { JsonValue, JsonPrimitive } from "./canonical.js";
|
|
10
10
|
export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { DecidioSuspendedError } from "./resume.js";
|
|
2
|
-
import { signBoundProof, signConfirm, assertSafeTransport } from "./signing.js";
|
|
2
|
+
import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
3
3
|
import { canonicalize, canonicalizeArgs } from "./canonical.js";
|
|
4
4
|
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
5
5
|
export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
|
|
6
6
|
export { guard, Guard } from "./guard.js";
|
|
7
7
|
export { DecidioHttpError } from "./errors.js";
|
|
8
8
|
import { DecidioHttpError } from "./errors.js";
|
|
9
|
-
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport } from "./signing.js";
|
|
9
|
+
export { generateKeypair, didFromPrivateKey, signBoundProof, signConfirm, signExecutionReport, verifyExecutionReport, resultDigest, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
10
10
|
export { canonicalize, canonicalizeArgs, canonicalJson, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
11
11
|
export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS } from "./evidence.js";
|
|
12
12
|
export class DecidioBlockedError extends Error {
|
|
@@ -80,7 +80,7 @@ async function reach(f, url, op, init) {
|
|
|
80
80
|
}
|
|
81
81
|
async function post(cfg, path, body) {
|
|
82
82
|
const f = cfg.fetchImpl ?? fetch;
|
|
83
|
-
assertSafeTransport(cfg.apiUrl,
|
|
83
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
84
84
|
const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `POST ${path}`, {
|
|
85
85
|
method: "POST",
|
|
86
86
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
@@ -103,7 +103,7 @@ async function post(cfg, path, body) {
|
|
|
103
103
|
}
|
|
104
104
|
async function get(cfg, path) {
|
|
105
105
|
const f = cfg.fetchImpl ?? fetch;
|
|
106
|
-
assertSafeTransport(cfg.apiUrl,
|
|
106
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
107
107
|
const res = await reach(f, cfg.apiUrl.replace(/\/$/, "") + path, `GET ${path}`, {
|
|
108
108
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
109
109
|
});
|
|
@@ -151,6 +151,8 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
151
151
|
});
|
|
152
152
|
if (auth.decision !== "proceed" && auth.decision !== "route" && auth.decision !== "block")
|
|
153
153
|
throw new DecidioBlockedError(`unrecognized gate verdict: ${String(auth.decision)}`, auth.decisionId);
|
|
154
|
+
if (typeof auth.decisionId !== "string" || !auth.decisionId)
|
|
155
|
+
throw new DecidioBlockedError(`the gate answered "${auth.decision}" with no usable decisionId (${JSON.stringify(auth.decisionId ?? null)}) — refusing to execute. Every authorized action must be addressable: without an id it cannot be polled, resumed, confirmed or audited.`, auth.decisionId);
|
|
154
156
|
if (auth.decision === "block")
|
|
155
157
|
throw new DecidioBlockedError(auth.reason ?? "blocked by policy", auth.decisionId);
|
|
156
158
|
if (auth.decision === "route") {
|
|
@@ -163,7 +165,7 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
163
165
|
config.resume.agentId ??= config.agentId;
|
|
164
166
|
config.resume.agentKey ??= config.agentKey;
|
|
165
167
|
config.resume.agentDid ??= config.agentDid;
|
|
166
|
-
config.resume.register(ctx.action, fn);
|
|
168
|
+
config.resume.register(ctx.action, fn, opts?.evidence);
|
|
167
169
|
await config.resume.park({
|
|
168
170
|
decisionId: auth.decisionId, receiptId: auth.receiptId,
|
|
169
171
|
action: ctx.action, amount: ctx.amount, scope: ctx.scope, args,
|
|
@@ -187,16 +189,26 @@ export function withApproval(fn, describe, config, opts) {
|
|
|
187
189
|
}
|
|
188
190
|
}
|
|
189
191
|
const result = await fn(...args);
|
|
192
|
+
let reported = null;
|
|
193
|
+
let evidenceProblem;
|
|
190
194
|
try {
|
|
191
|
-
const confirmIdentity = signConfirm(auth.decisionId, config);
|
|
192
195
|
const evidenceOf = (opts?.evidence ?? ((r) => r));
|
|
193
|
-
|
|
196
|
+
reported = canonicalize(evidenceOf(result) ?? null, "evidence(result)");
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
evidenceProblem = String(e?.message ?? e);
|
|
200
|
+
console.warn(`[decidio] ${auth.decisionId}: the action SUCCEEDED but its result could not be reported as evidence (${evidenceProblem}). Reporting an empty captured response so the execution is still recorded — map the return value with the \`evidence\` option (e.g. evidence: (r) => ({ recordId: String(r.id), success: true })) to earn a confirming tier. The action was NOT re-run.`);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const confirmIdentity = signConfirm(auth.decisionId, config);
|
|
194
204
|
const confirmBody = await post(config, "/agent/confirm", buildConfirmBody(config, auth.decisionId, reported, confirmIdentity));
|
|
195
205
|
const ev = readConfirmResponse(confirmBody, { signed: !!confirmIdentity, capturedResponse: reported });
|
|
196
206
|
if (!ev.reportRecorded)
|
|
197
207
|
console.warn(`[decidio] ${auth.decisionId}: ${ev.diagnostic}`);
|
|
198
|
-
else if (!ev.executionConfirmed)
|
|
208
|
+
else if (!ev.executionConfirmed || ev.executionReportVerified === false)
|
|
199
209
|
console.warn(`[decidio] execution evidence for ${auth.decisionId} recorded as ${ev.evidenceTier} — ${ev.diagnostic}`);
|
|
210
|
+
if (evidenceProblem)
|
|
211
|
+
console.warn(`[decidio] ${auth.decisionId}: the execution IS recorded, but with an empty captured response — see the evidence note above.`);
|
|
200
212
|
}
|
|
201
213
|
catch (e) {
|
|
202
214
|
console.warn(`[decidio] confirmation post failed (action succeeded): ${e.message}`);
|
package/dist/resume.d.ts
CHANGED
|
@@ -142,6 +142,9 @@ export interface ResumeOutcome {
|
|
|
142
142
|
executionConfirmed?: boolean;
|
|
143
143
|
/** executed only: the evidence tier Decidio recorded, verbatim. */
|
|
144
144
|
evidenceTier?: EvidenceTier;
|
|
145
|
+
/** executed only, when the gate reported on the SIGNED execution report: whether the
|
|
146
|
+
* attestation proof VERIFIED (C3, 0.1.12). */
|
|
147
|
+
executionReportVerified?: boolean;
|
|
145
148
|
/** executed only: why the evidence landed where it did, when that is worth explaining. */
|
|
146
149
|
diagnostic?: string;
|
|
147
150
|
}
|
|
@@ -191,7 +194,7 @@ export interface ResumeController extends ResumeConfig {
|
|
|
191
194
|
/** Register a RAW fn under its action name so a (possibly cold) resume can replay
|
|
192
195
|
* it. `withApproval` auto-registers on the hot path; a cold worker/service should
|
|
193
196
|
* register at startup so it can resume actions it never originated. */
|
|
194
|
-
register(action: string, fn: (...args: any[]) => Promise<any
|
|
197
|
+
register(action: string, fn: (...args: any[]) => Promise<any>, evidence?: (result: any) => unknown): void;
|
|
195
198
|
/** Park a routed action WITH its integrity binding (prefer this over store.put — the
|
|
196
199
|
* binding is what lets resolve() refuse a tampered payload). */
|
|
197
200
|
park(p: Omit<PendingAction, "createdAt" | "argsSig">): Promise<void>;
|
package/dist/resume.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { userInfo } from "node:os";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
|
-
import { signConfirm, canonicalize as jcs, assertSafeTransport } from "./signing.js";
|
|
6
|
+
import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
7
7
|
import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
8
8
|
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
9
9
|
import { DecidioHttpError } from "./errors.js";
|
|
@@ -172,9 +172,9 @@ function buildResumeConfirmRequest(cfg, decisionId, canonicalResult) {
|
|
|
172
172
|
console.warn(`[decidio] execution evidence for ${decisionId} will be recorded as an unproven agent assertion — no signing key configured. Set DECIDIO_AGENT_KEY/DID to earn a confirming tier.`);
|
|
173
173
|
return buildConfirmBody({ agentId: cfg.agentId ?? "agent", agentKey: cfg.agentKey, agentDid: cfg.agentDid, workspaceId: cfg.workspaceId }, decisionId, canonicalResult, requesterIdentity);
|
|
174
174
|
}
|
|
175
|
-
async function postConfirm(cfg, decisionId, body) {
|
|
175
|
+
async function postConfirm(cfg, decisionId, body, journalled = true) {
|
|
176
176
|
const f = cfg.fetchImpl ?? fetch;
|
|
177
|
-
assertSafeTransport(cfg.apiUrl,
|
|
177
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
178
178
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
179
179
|
method: "POST",
|
|
180
180
|
headers: { "content-type": "application/json", ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
@@ -190,11 +190,11 @@ async function postConfirm(cfg, decisionId, body) {
|
|
|
190
190
|
throw new DecidioHttpError(r.status, "/agent/confirm", parsed);
|
|
191
191
|
}
|
|
192
192
|
const parsed = await r.json().catch(() => null);
|
|
193
|
-
return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled
|
|
193
|
+
return readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: body.capturedResponse, journalled });
|
|
194
194
|
}
|
|
195
195
|
async function statusOf(cfg, decisionId) {
|
|
196
196
|
const f = cfg.fetchImpl ?? fetch;
|
|
197
|
-
assertSafeTransport(cfg.apiUrl,
|
|
197
|
+
assertSafeTransport(cfg.apiUrl, carriesCredential(cfg));
|
|
198
198
|
const r = await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/status/" + encodeURIComponent(decisionId), {
|
|
199
199
|
headers: { ...(cfg.apiToken ? { authorization: `Bearer ${cfg.apiToken}` } : {}) },
|
|
200
200
|
});
|
|
@@ -210,13 +210,21 @@ class ReportJournal {
|
|
|
210
210
|
mkdirSync(dir, { recursive: true });
|
|
211
211
|
hardenDir(dir);
|
|
212
212
|
}
|
|
213
|
-
catch {
|
|
213
|
+
catch (e) {
|
|
214
|
+
console.error(`[decidio] could not create the execution-report journal directory ${dir} (${e?.message ?? e}) — reports will NOT be locally recoverable until this is fixed. Point it somewhere writable with reportDir (or DECIDIO_PENDING_DIR).`);
|
|
215
|
+
}
|
|
214
216
|
}
|
|
215
217
|
p(id) { return join(this.dir, `${id.replace(/[^\w.-]/g, "_")}.json`); }
|
|
216
|
-
write(e) {
|
|
217
|
-
|
|
218
|
+
write(e) {
|
|
219
|
+
try {
|
|
220
|
+
writeFileSync(this.p(e.decisionId), JSON.stringify(e, null, 2), { mode: 0o600 });
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
console.error(`[decidio] ${e.decisionId}: the action RAN but its execution report could NOT be journalled to ${this.dir} (${err?.message ?? err}). If the report also fails to send, this execution is NOT locally recoverable — record the outcome from your own system. Fix the journal location with reportDir (or DECIDIO_PENDING_DIR).`);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
218
227
|
}
|
|
219
|
-
catch { } }
|
|
220
228
|
read(id) { try {
|
|
221
229
|
return existsSync(this.p(id)) ? JSON.parse(readFileSync(this.p(id), "utf8")) : null;
|
|
222
230
|
}
|
|
@@ -256,8 +264,9 @@ export function createDecidioResume(opts) {
|
|
|
256
264
|
let reported;
|
|
257
265
|
let haveReported = false;
|
|
258
266
|
let buildProblem;
|
|
267
|
+
const evidenceMapper = registry.get(pending.action)?.evidence ?? ctrl.evidence;
|
|
259
268
|
try {
|
|
260
|
-
reported = toCanonical((
|
|
269
|
+
reported = toCanonical((evidenceMapper ? evidenceMapper(result) : result) ?? null, "evidence(result)");
|
|
261
270
|
haveReported = true;
|
|
262
271
|
}
|
|
263
272
|
catch (e) {
|
|
@@ -265,8 +274,7 @@ export function createDecidioResume(opts) {
|
|
|
265
274
|
reported = null;
|
|
266
275
|
haveReported = true;
|
|
267
276
|
}
|
|
268
|
-
|
|
269
|
-
journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
|
|
277
|
+
const journalled = haveReported && journal.write({ decisionId, action: pending.action, reported, at: new Date().toISOString() });
|
|
270
278
|
let confirmRequest;
|
|
271
279
|
try {
|
|
272
280
|
confirmRequest = buildResumeConfirmRequest(ctrl, decisionId, reported);
|
|
@@ -274,19 +282,32 @@ export function createDecidioResume(opts) {
|
|
|
274
282
|
catch {
|
|
275
283
|
confirmRequest = undefined;
|
|
276
284
|
}
|
|
285
|
+
const deletePending = async () => {
|
|
286
|
+
try {
|
|
287
|
+
await ctrl.store.delete(decisionId);
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
console.error(`[decidio] ${decisionId}: the action RAN but its pending entry could NOT be deleted (${e?.message ?? e}) — the entry SURVIVED. Remove it from the pending store by hand before another process or a restart can see it, or the committed action may be executed again.`);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
277
293
|
if (!confirmRequest) {
|
|
278
|
-
await
|
|
294
|
+
await deletePending();
|
|
279
295
|
const diagnostic = `"${pending.action}" EXECUTED, but no execution report could be built (${buildProblem ?? "unknown"}). Nothing will re-run it; record the outcome from your own system.`;
|
|
280
296
|
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
281
297
|
return { ...base, reportRecorded: false, executionConfirmed: false, diagnostic };
|
|
282
298
|
}
|
|
283
|
-
await
|
|
299
|
+
await deletePending();
|
|
284
300
|
let ev;
|
|
285
301
|
try {
|
|
286
|
-
ev = await postConfirm(ctrl, decisionId, confirmRequest);
|
|
302
|
+
ev = await postConfirm(ctrl, decisionId, confirmRequest, journalled);
|
|
287
303
|
}
|
|
288
304
|
catch (e) {
|
|
289
|
-
ev = {
|
|
305
|
+
ev = {
|
|
306
|
+
reportRecorded: false, executionConfirmed: false,
|
|
307
|
+
diagnostic: journalled
|
|
308
|
+
? `the execution report did not reach Decidio (${e?.message ?? e}). Your action DID run and the report is journalled - re-send it with reconfirm("${decisionId}").`
|
|
309
|
+
: `the execution report did not reach Decidio (${e?.message ?? e}) AND could not be journalled locally, so nothing here can re-send it. Your action DID run - record the outcome from your own system.`,
|
|
310
|
+
};
|
|
290
311
|
}
|
|
291
312
|
if (ev.reportRecorded)
|
|
292
313
|
journal.clear(decisionId);
|
|
@@ -295,9 +316,9 @@ export function createDecidioResume(opts) {
|
|
|
295
316
|
: ev.diagnostic;
|
|
296
317
|
if (!ev.reportRecorded)
|
|
297
318
|
console.warn(`[decidio] ${decisionId}: ${diagnostic}`);
|
|
298
|
-
else if (!ev.executionConfirmed)
|
|
319
|
+
else if (!ev.executionConfirmed || ev.executionReportVerified === false)
|
|
299
320
|
console.warn(`[decidio] execution evidence for ${decisionId} recorded as ${ev.evidenceTier} - ${diagnostic}`);
|
|
300
|
-
return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(diagnostic ? { diagnostic } : {}) };
|
|
321
|
+
return { ...base, reportRecorded: ev.reportRecorded, executionConfirmed: ev.executionConfirmed, ...(ev.evidenceTier ? { evidenceTier: ev.evidenceTier } : {}), ...(ev.executionReportVerified !== undefined ? { executionReportVerified: ev.executionReportVerified } : {}), ...(diagnostic ? { diagnostic } : {}) };
|
|
301
322
|
}
|
|
302
323
|
const refusalWarned = new Set();
|
|
303
324
|
const refuseOnce = (decisionId, msg) => {
|
|
@@ -314,11 +335,13 @@ export function createDecidioResume(opts) {
|
|
|
314
335
|
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
315
336
|
webhookSecret: secret,
|
|
316
337
|
store: opts.store,
|
|
317
|
-
register(action, fn) {
|
|
338
|
+
register(action, fn, evidence) {
|
|
318
339
|
const prev = registry.get(action);
|
|
319
|
-
if (prev && prev !== fn)
|
|
340
|
+
if (prev && prev.fn !== fn)
|
|
320
341
|
console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT function — the latest one wins, and a resumed decision would run IT. Give each protected function its own action name.`);
|
|
321
|
-
|
|
342
|
+
if (prev && prev.evidence && evidence && prev.evidence !== evidence)
|
|
343
|
+
console.warn(`[decidio] action "${action}" is being registered again with a DIFFERENT evidence mapper — the latest one wins, and a resumed decision's record would carry ITS output. Give each protected function its own action name.`);
|
|
344
|
+
registry.set(action, { fn, evidence: evidence ?? prev?.evidence });
|
|
322
345
|
},
|
|
323
346
|
async park(p) {
|
|
324
347
|
try {
|
|
@@ -368,7 +391,7 @@ export function createDecidioResume(opts) {
|
|
|
368
391
|
}
|
|
369
392
|
if (integrity === "unbound")
|
|
370
393
|
console.warn(`[decidio] executing ${decisionId} from an UNBOUND parked entry (this agent has no key material to bind or verify payloads) — its arguments cannot be integrity-checked. Run \`npx @decidio/sdk init\` to enable binding.`);
|
|
371
|
-
const fn = registry.get(pending.action);
|
|
394
|
+
const fn = registry.get(pending.action)?.fn;
|
|
372
395
|
if (!fn) {
|
|
373
396
|
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
374
397
|
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
@@ -386,13 +409,11 @@ export function createDecidioResume(opts) {
|
|
|
386
409
|
return finish(await reportAfterExecution(pending, decisionId, result));
|
|
387
410
|
}
|
|
388
411
|
catch (e) {
|
|
412
|
+
inFlight.delete(decisionId);
|
|
389
413
|
if (ctrl.store.release)
|
|
390
414
|
await ctrl.store.release(decisionId).catch(() => { });
|
|
391
415
|
throw e;
|
|
392
416
|
}
|
|
393
|
-
finally {
|
|
394
|
-
inFlight.delete(decisionId);
|
|
395
|
-
}
|
|
396
417
|
},
|
|
397
418
|
webhookHandler() {
|
|
398
419
|
return (req, res) => {
|
package/dist/signing.d.ts
CHANGED
|
@@ -68,6 +68,14 @@ export declare function assertJsonSafe(value: unknown, path?: string): void;
|
|
|
68
68
|
* session tokens while agent-token requests sailed through). localhost/127.0.0.1/[::1] stay
|
|
69
69
|
* allowed for local dev; https always allowed. Throws before anything is sent. */
|
|
70
70
|
export declare function assertSafeTransport(url: string, hasCredential: boolean): void;
|
|
71
|
+
/** The ONE answer to "does this request carry a credential worth protecting?" (A6, 0.1.12).
|
|
72
|
+
* A signed identity proof is a credential too: sending it over remote plaintext disclosed
|
|
73
|
+
* request context and replay/front-run material even with no bearer configured. */
|
|
74
|
+
export declare const carriesCredential: (c: {
|
|
75
|
+
apiToken?: string;
|
|
76
|
+
agentKey?: string;
|
|
77
|
+
agentDid?: string;
|
|
78
|
+
}) => boolean;
|
|
71
79
|
/** A signed, DOMAIN-SEPARATED, versioned attestation that THIS agent reported THIS captured
|
|
72
80
|
* response for THIS decision. Distinct from the identity proof (`signConfirm`) in every field
|
|
73
81
|
* that matters, so a report can never be lifted from one decision onto another: the decision id,
|
package/dist/signing.js
CHANGED
|
@@ -113,10 +113,26 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
113
113
|
export function assertSafeTransport(url, hasCredential) {
|
|
114
114
|
if (!hasCredential)
|
|
115
115
|
return;
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
let u;
|
|
117
|
+
try {
|
|
118
|
+
u = new URL(url);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "");
|
|
122
|
+
if (/^http:/i.test(spelled)) {
|
|
123
|
+
throw new Error(`[decidio] refusing to send credentials over plaintext http to ${JSON.stringify(url)} — that is not a complete absolute URL, so where the request would actually connect is undefined (the path is appended to it, which can turn it into a remote host). Set a full https URL, or http://localhost for local dev. Nothing was sent.`);
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
118
126
|
}
|
|
127
|
+
if (u.protocol !== "http:")
|
|
128
|
+
return;
|
|
129
|
+
const host = u.hostname.toLowerCase();
|
|
130
|
+
const local = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
|
|
131
|
+
if (local && !u.username && !u.password)
|
|
132
|
+
return;
|
|
133
|
+
throw new Error(`[decidio] refusing to send credentials over plaintext http to ${u.origin} — use https, or localhost for local dev. Nothing was sent.`);
|
|
119
134
|
}
|
|
135
|
+
export const carriesCredential = (c) => !!c.apiToken || !!(c.agentKey && c.agentDid);
|
|
120
136
|
export function signExecutionReport(privateKeyBase64, fields) {
|
|
121
137
|
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
122
138
|
const nonce = fields.nonce ?? randomUUID();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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",
|