@decidio/sdk 0.1.9 → 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 +134 -29
- package/dist/adapters/_gate.d.ts +25 -1
- package/dist/adapters/_gate.js +69 -26
- package/dist/adapters/langgraph.js +2 -15
- package/dist/adapters/openai.js +2 -5
- package/dist/adapters/temporal.js +18 -20
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.js +104 -0
- package/dist/cli.js +146 -272
- 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 +29 -8
- package/dist/guard.js +28 -35
- package/dist/index.d.ts +19 -10
- package/dist/index.js +49 -113
- package/dist/inngest.js +6 -56
- package/dist/resume.d.ts +83 -26
- package/dist/resume.js +213 -150
- package/dist/signing.d.ts +72 -6
- package/dist/signing.js +107 -41
- package/openapi.yaml +47 -1
- package/package.json +3 -9
package/dist/signing.js
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// (the private key never crosses the wire — `init` registers only the public did:key) and
|
|
3
|
-
// signs a REQUEST-BOUND proof on each authorize: a detached Ed25519 JWS over
|
|
4
|
-
// canonical({agentId,did,action,amount,scope,nonce,exp}). Decidio's floor verifies the
|
|
5
|
-
// signature, binds it to the exact request, burns the single-use nonce, and checks the
|
|
6
|
-
// did against the agent registry — so the gate PROVES who is calling instead of trusting a
|
|
7
|
-
// label. This canonicalize + proof shape is byte-identical to apps/api (canonical.ts +
|
|
8
|
-
// a2a/identity.ts), so a proof signed here verifies there.
|
|
9
|
-
import { generateKeyPairSync, createPrivateKey, sign as edSign, randomUUID } from "node:crypto";
|
|
10
|
-
// base58btc (Bitcoin alphabet) — for the did:key multibase 'z' prefix.
|
|
1
|
+
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, createHash, randomUUID } from "node:crypto";
|
|
11
2
|
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
12
3
|
function b58encode(buf) {
|
|
13
4
|
const digits = [0];
|
|
@@ -34,7 +25,6 @@ function b58encode(buf) {
|
|
|
34
25
|
str += B58[digits[i]];
|
|
35
26
|
return str;
|
|
36
27
|
}
|
|
37
|
-
/** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
|
|
38
28
|
export function canonicalize(v) {
|
|
39
29
|
if (v === null || typeof v !== "object")
|
|
40
30
|
return JSON.stringify(v);
|
|
@@ -43,9 +33,11 @@ export function canonicalize(v) {
|
|
|
43
33
|
const o = v;
|
|
44
34
|
return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(o[k])}`).join(",")}}`;
|
|
45
35
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
export function didFromPrivateKey(privateKeyBase64) {
|
|
37
|
+
const priv = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
38
|
+
const jwk = createPublicKey(priv).export({ format: "jwk" });
|
|
39
|
+
return "did:key:z" + b58encode(Buffer.concat([Buffer.from([0xed, 0x01]), Buffer.from(jwk.x, "base64url")]));
|
|
40
|
+
}
|
|
49
41
|
export function generateKeypair() {
|
|
50
42
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
51
43
|
const jwk = publicKey.export({ format: "jwk" });
|
|
@@ -54,40 +46,29 @@ export function generateKeypair() {
|
|
|
54
46
|
const privateKeyBase64 = privateKey.export({ format: "der", type: "pkcs8" }).toString("base64");
|
|
55
47
|
return { did, privateKeyBase64 };
|
|
56
48
|
}
|
|
57
|
-
/** Sign the CONFIRM call (bound to {action:"confirm", scope:decisionId}) so the gate can
|
|
58
|
-
* prove the caller is the receipt's original requester before granting the wrapper tier
|
|
59
|
-
* (application_confirmed). Returns undefined when no signing key is configured (the server
|
|
60
|
-
* then caps the report at agent_asserted). Used by EVERY confirm path — inline, durable
|
|
61
|
-
* resume, and the engine adapters — so the production paths confirm with a proof too. Also
|
|
62
|
-
* binds `workspaceId` (D-B3.6) when configured, so the confirm proof is tenant-scoped too. */
|
|
63
49
|
export function signConfirm(decisionId, cfg) {
|
|
64
50
|
if (!cfg.agentKey || !cfg.agentDid || !cfg.agentId)
|
|
65
51
|
return undefined;
|
|
66
52
|
return signBoundProof(cfg.agentKey, { agentId: cfg.agentId, did: cfg.agentDid, action: "confirm", amount: 0, scope: decisionId, workspaceId: cfg.workspaceId });
|
|
67
53
|
}
|
|
68
|
-
/** Sign a request-bound identity proof. The returned object is sent verbatim as the
|
|
69
|
-
* `requesterIdentity` field of /agent/authorize. */
|
|
70
54
|
export function signBoundProof(privateKeyBase64, fields) {
|
|
71
55
|
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
72
56
|
const nonce = fields.nonce ?? randomUUID();
|
|
73
57
|
const exp = (fields.now ?? Date.now()) + (fields.ttlMs ?? 120_000);
|
|
74
|
-
// OMIT scope/workspaceId when absent so the canonical bytes match the server + the Python SDK — a key
|
|
75
|
-
// with an undefined value would serialize as literal "undefined", which Python can't reproduce, and a
|
|
76
|
-
// legacy (no-workspace) proof must stay byte-identical. The server's bound-payload reconstruction
|
|
77
|
-
// omits them the same way (a2a/identity.ts).
|
|
78
58
|
const payloadObj = { agentId: fields.agentId, did: fields.did, action: fields.action, amount: fields.amount, ...(fields.scope !== undefined ? { scope: fields.scope } : {}), ...(fields.workspaceId !== undefined ? { workspaceId: fields.workspaceId } : {}), nonce, exp };
|
|
79
59
|
const payload = Buffer.from(canonicalize(payloadObj), "utf8").toString("base64url");
|
|
80
60
|
const protectedHdr = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: fields.did }), "utf8").toString("base64url");
|
|
81
61
|
const signature = Buffer.from(edSign(null, Buffer.from(`${protectedHdr}.${payload}`, "utf8"), key)).toString("base64url");
|
|
82
62
|
return { agentId: fields.agentId, did: fields.did, action: fields.action, amount: fields.amount, scope: fields.scope, workspaceId: fields.workspaceId, nonce, exp, proof: { protected: protectedHdr, signature } };
|
|
83
63
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
64
|
+
export class DecidioUnsafeArgumentsError extends Error {
|
|
65
|
+
decisionId;
|
|
66
|
+
constructor(message, decisionId) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.decisionId = decisionId;
|
|
69
|
+
this.name = "DecidioUnsafeArgumentsError";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
91
72
|
export function assertJsonSafe(value, path = "args") {
|
|
92
73
|
const seen = new Set();
|
|
93
74
|
const walk = (v, p) => {
|
|
@@ -95,18 +76,18 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
95
76
|
return;
|
|
96
77
|
if (typeof v === "number") {
|
|
97
78
|
if (!Number.isFinite(v))
|
|
98
|
-
throw new
|
|
79
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is ${String(v)} — a non-finite number does not survive JSON (it becomes null), so what a human approves would not be what executes. pass a finite number or a string.`);
|
|
99
80
|
return;
|
|
100
81
|
}
|
|
101
82
|
if (typeof v === "undefined")
|
|
102
|
-
throw new
|
|
83
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is undefined — JSON drops or nullifies it, so what a human approves would not be what executes. use null explicitly or omit the field.`);
|
|
103
84
|
if (typeof v === "bigint")
|
|
104
|
-
throw new
|
|
85
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is a BigInt — JSON cannot represent it. pass it as a string.`);
|
|
105
86
|
if (typeof v === "function" || typeof v === "symbol")
|
|
106
|
-
throw new
|
|
87
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is a ${typeof v} — it cannot be serialized for approval.`);
|
|
107
88
|
if (typeof v === "object") {
|
|
108
89
|
if (seen.has(v))
|
|
109
|
-
throw new
|
|
90
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is part of a circular structure — it cannot be serialized for approval.`);
|
|
110
91
|
seen.add(v);
|
|
111
92
|
if (Array.isArray(v)) {
|
|
112
93
|
v.forEach((x, i) => walk(x, `${p}[${i}]`));
|
|
@@ -116,16 +97,101 @@ export function assertJsonSafe(value, path = "args") {
|
|
|
116
97
|
const proto = Object.getPrototypeOf(v);
|
|
117
98
|
if (proto !== Object.prototype && proto !== null) {
|
|
118
99
|
const name = v?.constructor?.name ?? "a class instance";
|
|
119
|
-
throw new
|
|
100
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} is ${name === "Object" ? "an exotic object" : `a ${name}`} — only plain JSON objects survive serialization with their meaning intact (a Map, for example, becomes {}); convert it to plain JSON first.`);
|
|
120
101
|
}
|
|
121
102
|
if (typeof v.toJSON === "function")
|
|
122
|
-
throw new
|
|
103
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} defines toJSON() — its serialized form would differ from the value the signature covers. pass the plain serialized form instead.`);
|
|
123
104
|
for (const [k, x] of Object.entries(v))
|
|
124
105
|
walk(x, `${p}.${k}`);
|
|
125
106
|
seen.delete(v);
|
|
126
107
|
return;
|
|
127
108
|
}
|
|
128
|
-
throw new
|
|
109
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] ${p} has unsupported type ${typeof v}.`);
|
|
129
110
|
};
|
|
130
111
|
walk(value, path);
|
|
131
112
|
}
|
|
113
|
+
export function assertSafeTransport(url, hasCredential) {
|
|
114
|
+
if (!hasCredential)
|
|
115
|
+
return;
|
|
116
|
+
if (/^http:\/\//i.test(url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(url)) {
|
|
117
|
+
throw new Error(`[decidio] refusing to send credentials over plaintext http to ${url.replace(/^(https?:\/\/[^/]+).*$/i, "$1")} — use https, or localhost for local dev. Nothing was sent.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function signExecutionReport(privateKeyBase64, fields) {
|
|
121
|
+
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
122
|
+
const nonce = fields.nonce ?? randomUUID();
|
|
123
|
+
const exp = (fields.now ?? Date.now()) + (fields.ttlMs ?? 300_000);
|
|
124
|
+
const body = {
|
|
125
|
+
v: 1,
|
|
126
|
+
purpose: "decidio.execution-report",
|
|
127
|
+
decisionId: fields.decisionId,
|
|
128
|
+
agentId: fields.agentId,
|
|
129
|
+
did: fields.did,
|
|
130
|
+
...(fields.workspaceId !== undefined ? { workspaceId: fields.workspaceId } : {}),
|
|
131
|
+
source: fields.source,
|
|
132
|
+
resultDigest: fields.resultDigest,
|
|
133
|
+
digestAlg: "sha-256",
|
|
134
|
+
canonicalization: "decidio-jcs-1",
|
|
135
|
+
nonce,
|
|
136
|
+
exp,
|
|
137
|
+
};
|
|
138
|
+
const payload = Buffer.from(canonicalize(body), "utf8").toString("base64url");
|
|
139
|
+
const protectedHdr = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: fields.did, typ: "decidio-execution-report+jws" }), "utf8").toString("base64url");
|
|
140
|
+
const signature = Buffer.from(edSign(null, Buffer.from(`${protectedHdr}.${payload}`, "utf8"), key)).toString("base64url");
|
|
141
|
+
return { ...body, proof: { protected: protectedHdr, signature } };
|
|
142
|
+
}
|
|
143
|
+
export function verifyExecutionReport(report, expect) {
|
|
144
|
+
const r = report;
|
|
145
|
+
if (!r || r.purpose !== "decidio.execution-report" || r.v !== 1)
|
|
146
|
+
return { valid: false, reason: "not a v1 decidio execution report" };
|
|
147
|
+
if (r.decisionId !== expect.decisionId)
|
|
148
|
+
return { valid: false, reason: "report is bound to a DIFFERENT decision" };
|
|
149
|
+
if (expect.did && r.did !== expect.did)
|
|
150
|
+
return { valid: false, reason: "report was signed by a different agent key" };
|
|
151
|
+
if (typeof r.exp !== "number" || (expect.now ?? Date.now()) > r.exp)
|
|
152
|
+
return { valid: false, reason: "report expired" };
|
|
153
|
+
if (r.digestAlg !== "sha-256" || r.canonicalization !== "decidio-jcs-1")
|
|
154
|
+
return { valid: false, reason: "unsupported digest/canonicalization" };
|
|
155
|
+
try {
|
|
156
|
+
const { proof, ...body } = r;
|
|
157
|
+
const payload = Buffer.from(canonicalize(body), "utf8").toString("base64url");
|
|
158
|
+
const mc = b58decode(String(r.did).slice("did:key:z".length));
|
|
159
|
+
if (mc[0] !== 0xed || mc[1] !== 0x01)
|
|
160
|
+
return { valid: false, reason: "issuer did is not Ed25519" };
|
|
161
|
+
const pub = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(mc.subarray(2)).toString("base64url") }, format: "jwk" });
|
|
162
|
+
const ok = edVerify(null, Buffer.from(`${proof.protected}.${payload}`, "utf8"), pub, Buffer.from(proof.signature, "base64url"));
|
|
163
|
+
return ok ? { valid: true } : { valid: false, reason: "signature does not verify against the agent did" };
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
return { valid: false, reason: `malformed report (${e?.message ?? e})` };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
export function resultDigest(canonicalValue) {
|
|
170
|
+
return createHash("sha256").update(canonicalize(canonicalValue), "utf8").digest("hex");
|
|
171
|
+
}
|
|
172
|
+
function b58decode(s) {
|
|
173
|
+
const A = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
174
|
+
const bytes = [0];
|
|
175
|
+
for (const ch of s) {
|
|
176
|
+
const val = A.indexOf(ch);
|
|
177
|
+
if (val < 0)
|
|
178
|
+
throw new Error("bad base58 character");
|
|
179
|
+
let carry = val;
|
|
180
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
181
|
+
carry += bytes[i] * 58;
|
|
182
|
+
bytes[i] = carry & 0xff;
|
|
183
|
+
carry >>= 8;
|
|
184
|
+
}
|
|
185
|
+
while (carry) {
|
|
186
|
+
bytes.push(carry & 0xff);
|
|
187
|
+
carry >>= 8;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for (const ch of s) {
|
|
191
|
+
if (ch === A[0])
|
|
192
|
+
bytes.push(0);
|
|
193
|
+
else
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
return Buffer.from(bytes.reverse());
|
|
197
|
+
}
|
package/openapi.yaml
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
openapi: 3.1.0
|
|
6
6
|
info:
|
|
7
7
|
title: Decidio Agent Gate
|
|
8
|
-
version: 0.1.
|
|
8
|
+
version: 0.1.11
|
|
9
9
|
description: >
|
|
10
10
|
Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
|
|
11
11
|
receive the signed resume webhook when a routed action is decided. Every outcome seals a
|
|
@@ -101,6 +101,52 @@ components:
|
|
|
101
101
|
requesterIdentity:
|
|
102
102
|
$ref: "#/components/schemas/RequesterIdentity"
|
|
103
103
|
description: confirm proof is bound to {action:"confirm", scope:decisionId}
|
|
104
|
+
executionReport:
|
|
105
|
+
$ref: "#/components/schemas/ExecutionReport"
|
|
106
|
+
ExecutionReport:
|
|
107
|
+
type: object
|
|
108
|
+
description: >
|
|
109
|
+
OPTIONAL proof of WHAT was executed: a domain-separated, versioned envelope over a digest
|
|
110
|
+
of the captured response, bound to the decision, the agent and the workspace. The server
|
|
111
|
+
VERIFIES it before persisting anything — it recomputes the digest from `capturedResponse`
|
|
112
|
+
on this same request, requires the signer to be the decision's original requesting agent
|
|
113
|
+
AND still registered and active, and burns the nonce so a report is single-use. A report
|
|
114
|
+
copied onto a different decision, a different agent, or an altered response is refused.
|
|
115
|
+
|
|
116
|
+
A REFUSED REPORT DOES NOT FAIL THE CONFIRM. This envelope grants no authority — the
|
|
117
|
+
evidence tier comes from the response, the identity from the confirm proof — so an
|
|
118
|
+
unverifiable attestation is refused as an ATTESTATION (never stored as evidence, and the
|
|
119
|
+
refusal is itself audited) while the confirmation is still recorded. The response reports
|
|
120
|
+
which happened via `executionReport.verified`; it is never silent. Omit the field entirely
|
|
121
|
+
and confirm behaves exactly as before.
|
|
122
|
+
required: [v, purpose, decisionId, agentId, did, source, resultDigest, digestAlg, canonicalization, nonce, exp, proof]
|
|
123
|
+
properties:
|
|
124
|
+
v: { type: integer, enum: [1] }
|
|
125
|
+
purpose: { type: string, enum: ["decidio.execution-report"], description: domain separation — this signature can never be replayed as another Decidio proof }
|
|
126
|
+
decisionId: { type: string }
|
|
127
|
+
agentId: { type: string }
|
|
128
|
+
did: { type: string, description: "did:key of the signing agent" }
|
|
129
|
+
workspaceId: { type: string, description: tenant binding; present when the agent knows it }
|
|
130
|
+
source: { type: string, enum: [wrapper, agent] }
|
|
131
|
+
resultDigest:
|
|
132
|
+
type: string
|
|
133
|
+
pattern: "^[0-9a-f]{64}$"
|
|
134
|
+
description: lowercase-hex SHA-256 over the canonicalized captured response (the server recomputes it and compares)
|
|
135
|
+
digestAlg: { type: string, enum: ["sha-256"] }
|
|
136
|
+
canonicalization: { type: string, enum: ["decidio-jcs-1"], description: RFC 8785 JCS with Decidio's refusal rules }
|
|
137
|
+
nonce:
|
|
138
|
+
type: string
|
|
139
|
+
pattern: "^[A-Za-z0-9._:-]{8,64}$"
|
|
140
|
+
description: single-use; burned server-side, so a replayed report is refused
|
|
141
|
+
exp:
|
|
142
|
+
type: integer
|
|
143
|
+
description: unix ms expiry. The server also refuses an expiry more than 15 minutes ahead of now — a client-chosen lifetime is not freshness.
|
|
144
|
+
proof:
|
|
145
|
+
type: object
|
|
146
|
+
required: [protected, signature]
|
|
147
|
+
properties:
|
|
148
|
+
protected: { type: string, description: "base64url JWS protected header (EdDSA, kid=did, typ=decidio-execution-report+jws)" }
|
|
149
|
+
signature: { type: string, description: base64url detached signature over protected.payload }
|
|
104
150
|
ResumeWebhook:
|
|
105
151
|
type: object
|
|
106
152
|
description: >
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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",
|
|
@@ -77,20 +77,14 @@
|
|
|
77
77
|
},
|
|
78
78
|
"scripts": {
|
|
79
79
|
"typecheck": "tsc --noEmit",
|
|
80
|
-
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
|
|
80
|
+
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
|
|
81
81
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@decidio/verify": "0.1.
|
|
84
|
+
"@decidio/verify": "0.1.10"
|
|
85
85
|
},
|
|
86
86
|
"author": "OmniTwin Technologies Inc.",
|
|
87
87
|
"bugs": {
|
|
88
88
|
"url": "https://decidioai.com/support/"
|
|
89
|
-
},
|
|
90
|
-
"peerDependencies": {
|
|
91
|
-
"inngest": ">=3 <4",
|
|
92
|
-
"@langchain/langgraph": ">=0.2 <2",
|
|
93
|
-
"@temporalio/workflow": ">=1 <2",
|
|
94
|
-
"@openai/agents": ">=0.0.1 <2"
|
|
95
89
|
}
|
|
96
90
|
}
|