@decidio/sdk 0.1.5 → 0.1.7
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 +57 -11
- package/dist/adapters/_gate.d.ts +0 -2
- package/dist/adapters/_gate.js +0 -15
- package/dist/adapters/langgraph.d.ts +0 -3
- package/dist/adapters/langgraph.js +0 -10
- package/dist/adapters/openai.d.ts +0 -4
- package/dist/adapters/openai.js +0 -20
- package/dist/adapters/temporal.d.ts +0 -1
- package/dist/adapters/temporal.js +1 -13
- package/dist/cli.js +149 -138
- package/dist/guard.d.ts +2 -14
- package/dist/guard.js +6 -19
- package/dist/index.d.ts +0 -41
- package/dist/index.js +3 -81
- package/dist/inngest.d.ts +0 -20
- package/dist/inngest.js +0 -26
- package/dist/resume.d.ts +5 -61
- package/dist/resume.js +107 -73
- package/dist/signing.d.ts +0 -12
- package/dist/signing.js +0 -25
- package/openapi.yaml +1 -1
- package/package.json +5 -1
package/dist/resume.js
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// The hard problem behind a "one-line" agent gate: a real human approval takes
|
|
4
|
-
// minutes to days. Nobody watches a 60-second polling window. So a routed action
|
|
5
|
-
// must SUSPEND (the requesting process may even exit) and RESUME later when the
|
|
6
|
-
// approval arrives — and the AGENT must perform its own action on resume. Decidio
|
|
7
|
-
// stays the approval layer above: it authorizes, records, and (on approval) SIGNALS
|
|
8
|
-
// the agent. It never holds the agent's downstream credentials and never executes
|
|
9
|
-
// the write itself. This is the same shape as HumanLayer's webhooks, Temporal
|
|
10
|
-
// Signals, Inngest waitForEvent, and Decidio's own A2A `input-required` state.
|
|
11
|
-
//
|
|
12
|
-
// Two resume transports, same core:
|
|
13
|
-
// • webhookHandler() — production: Decidio POSTs a signed verdict to the agent's
|
|
14
|
-
// resume URL; the handler re-executes the agent's own fn and confirms.
|
|
15
|
-
// • worker() — no inbound URL (laptop/CLI): a long-lived watcher polls
|
|
16
|
-
// /agent/status for each pending decision, then re-executes on approval.
|
|
17
|
-
// Either way the args are replayed from an AGENT-SIDE store (Decidio holds none of
|
|
18
|
-
// the downstream payload — only the authority decision), and execution is single-use
|
|
19
|
-
// (the pending entry is deleted on completion, so a duplicate signal can't double-write).
|
|
20
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHmac, timingSafeEqual, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from "node:crypto";
|
|
21
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, renameSync } from "node:fs";
|
|
22
3
|
import { join } from "node:path";
|
|
23
|
-
import { signConfirm } from "./signing.js";
|
|
4
|
+
import { signConfirm, canonicalize } from "./signing.js";
|
|
5
|
+
const argsBindingBytes = (p) => Buffer.from(canonicalize({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
|
|
6
|
+
function signArgs(p, agentKey, webhookSecret) {
|
|
7
|
+
const bytes = argsBindingBytes(p);
|
|
8
|
+
if (agentKey) {
|
|
9
|
+
try {
|
|
10
|
+
const key = createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" });
|
|
11
|
+
return { kind: "ed25519", sig: Buffer.from(edSign(null, bytes, key)).toString("base64url") };
|
|
12
|
+
}
|
|
13
|
+
catch { }
|
|
14
|
+
}
|
|
15
|
+
if (webhookSecret)
|
|
16
|
+
return { kind: "hmac", sig: createHmac("sha256", webhookSecret).update(bytes).digest("hex") };
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
function verifyArgs(p, agentKey, webhookSecret) {
|
|
20
|
+
if (!p.argsSig)
|
|
21
|
+
return "unbound";
|
|
22
|
+
const bytes = argsBindingBytes(p);
|
|
23
|
+
try {
|
|
24
|
+
if (p.argsSig.kind === "ed25519") {
|
|
25
|
+
if (!agentKey)
|
|
26
|
+
return "unverifiable";
|
|
27
|
+
const pub = createPublicKey(createPrivateKey({ key: Buffer.from(agentKey, "base64"), format: "der", type: "pkcs8" }));
|
|
28
|
+
return edVerify(null, bytes, pub, Buffer.from(p.argsSig.sig, "base64url")) ? "ok" : "mismatch";
|
|
29
|
+
}
|
|
30
|
+
if (!webhookSecret)
|
|
31
|
+
return "unverifiable";
|
|
32
|
+
const expected = createHmac("sha256", webhookSecret).update(bytes).digest("hex");
|
|
33
|
+
const a = Buffer.from(expected), b = Buffer.from(p.argsSig.sig);
|
|
34
|
+
return a.length === b.length && timingSafeEqual(a, b) ? "ok" : "mismatch";
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "mismatch";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
24
40
|
export class MemoryPendingStore {
|
|
25
41
|
m = new Map();
|
|
26
42
|
claimed = new Set();
|
|
@@ -32,11 +48,6 @@ export class MemoryPendingStore {
|
|
|
32
48
|
return false; this.claimed.add(id); return true; }
|
|
33
49
|
async release(id) { this.claimed.delete(id); }
|
|
34
50
|
}
|
|
35
|
-
/** File-backed store so a COLD process (the original requester long gone) can still
|
|
36
|
-
* resume — the proof that nothing is "kept alive". One JSON file per decision. A claim
|
|
37
|
-
* renames `<id>.json` → `<id>.claimed` atomically (same-volume rename), so two processes
|
|
38
|
-
* sharing the dir can't both execute. (A stale `.claimed` from a crash mid-execute needs
|
|
39
|
-
* TTL recovery — tracked in the backlog.) */
|
|
40
51
|
export class FilePendingStore {
|
|
41
52
|
dir;
|
|
42
53
|
constructor(dir) {
|
|
@@ -46,7 +57,7 @@ export class FilePendingStore {
|
|
|
46
57
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
47
58
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
48
59
|
claimedPath(id) { return join(this.dir, `${this.key(id)}.claimed`); }
|
|
49
|
-
async put(p) { writeFileSync(this.path(p.decisionId), JSON.stringify(p, null, 2)); }
|
|
60
|
+
async put(p) { writeFileSync(this.path(p.decisionId), JSON.stringify(p, null, 2), { mode: 0o600 }); }
|
|
50
61
|
async get(id) {
|
|
51
62
|
for (const f of [this.path(id), this.claimedPath(id)])
|
|
52
63
|
if (existsSync(f))
|
|
@@ -59,14 +70,22 @@ export class FilePendingStore {
|
|
|
59
70
|
async list() {
|
|
60
71
|
if (!existsSync(this.dir))
|
|
61
72
|
return [];
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const f of readdirSync(this.dir).filter((f) => f.endsWith(".json"))) {
|
|
75
|
+
try {
|
|
76
|
+
out.push(JSON.parse(readFileSync(join(this.dir, f), "utf8")));
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
64
83
|
}
|
|
65
84
|
async claim(id) {
|
|
66
85
|
try {
|
|
67
86
|
renameSync(this.path(id), this.claimedPath(id));
|
|
68
87
|
return true;
|
|
69
|
-
}
|
|
88
|
+
}
|
|
70
89
|
catch {
|
|
71
90
|
return false;
|
|
72
91
|
}
|
|
@@ -75,13 +94,9 @@ export class FilePendingStore {
|
|
|
75
94
|
try {
|
|
76
95
|
renameSync(this.claimedPath(id), this.path(id));
|
|
77
96
|
}
|
|
78
|
-
catch {
|
|
97
|
+
catch { }
|
|
79
98
|
}
|
|
80
99
|
}
|
|
81
|
-
/** Thrown by a `withApproval`-wrapped call when the action is routed and configured
|
|
82
|
-
* for async resume: the action is now SUSPENDED (parked in the store), not failed.
|
|
83
|
-
* The caller can stop here — the process may even exit; the resume transport will
|
|
84
|
-
* complete it later. */
|
|
85
100
|
export class DecidioSuspendedError extends Error {
|
|
86
101
|
decisionId;
|
|
87
102
|
action;
|
|
@@ -96,8 +111,6 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
96
111
|
function sign(secret, body) {
|
|
97
112
|
return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
|
|
98
113
|
}
|
|
99
|
-
/** Verify Decidio's `x-decidio-signature` over the raw webhook body (HMAC-SHA256).
|
|
100
|
-
* Exported so engine adapters (e.g. the Inngest bridge) reuse the exact check. */
|
|
101
114
|
export function verifyResumeSignature(secret, body, header) {
|
|
102
115
|
if (!header)
|
|
103
116
|
return false;
|
|
@@ -107,11 +120,7 @@ export function verifyResumeSignature(secret, body, header) {
|
|
|
107
120
|
}
|
|
108
121
|
async function confirm(cfg, decisionId, capturedResponse) {
|
|
109
122
|
const f = cfg.fetchImpl ?? fetch;
|
|
110
|
-
// Sign the confirm so the DURABLE/resume path can earn application_confirmed too (bound to
|
|
111
|
-
// {action:"confirm", scope:decisionId}); without a key the server caps it at agent_asserted.
|
|
112
123
|
const requesterIdentity = signConfirm(decisionId, cfg);
|
|
113
|
-
// Same no-silent-downgrade rule as the inline wrap (guard equivalent-path catch): the durable
|
|
114
|
-
// path is what production agents run, so it must disclose an agent_asserted cap too.
|
|
115
124
|
if (!requesterIdentity)
|
|
116
125
|
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.`);
|
|
117
126
|
await f(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", {
|
|
@@ -130,37 +139,36 @@ async function statusOf(cfg, decisionId) {
|
|
|
130
139
|
throw new Error(`status ${r.status}`);
|
|
131
140
|
return r.json();
|
|
132
141
|
}
|
|
133
|
-
/**
|
|
134
|
-
* Build the resume controller. Spread it into your GuardConfig as `resume`, then
|
|
135
|
-
* either mount `webhookHandler()` (production) or run `worker()` (CLI/laptop).
|
|
136
|
-
*
|
|
137
|
-
* const resume = createDecidioResume({ apiUrl, apiToken, store: new FilePendingStore(".decidio-pending"), resumeUrl });
|
|
138
|
-
* const guard = { apiUrl, agentId, apiToken, resume };
|
|
139
|
-
* const createOpportunity = withApproval(createOpportunityRaw, describe, guard);
|
|
140
|
-
* // production: http.createServer(resume.webhookHandler()).listen(4100)
|
|
141
|
-
* // or CLI: resume.worker()
|
|
142
|
-
*/
|
|
143
142
|
export function createDecidioResume(opts) {
|
|
144
143
|
const registry = new Map();
|
|
145
|
-
const inFlight = new Set();
|
|
144
|
+
const inFlight = new Set();
|
|
146
145
|
const secret = opts.webhookSecret ?? opts.apiToken ?? "";
|
|
147
146
|
const ctrl = {
|
|
148
147
|
...opts,
|
|
149
|
-
// Default the signing identity from env so a COLD resume process (a fresh worker that has
|
|
150
|
-
// the agent's env but wasn't passed config) still signs the confirm and earns the wrapper tier.
|
|
151
148
|
agentId: opts.agentId ?? process.env.DECIDIO_AGENT_ID,
|
|
152
149
|
agentKey: opts.agentKey ?? process.env.DECIDIO_AGENT_KEY,
|
|
153
150
|
agentDid: opts.agentDid ?? process.env.DECIDIO_AGENT_DID,
|
|
154
|
-
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
151
|
+
workspaceId: opts.workspaceId ?? process.env.DECIDIO_WORKSPACE_ID,
|
|
155
152
|
webhookSecret: secret,
|
|
156
153
|
store: opts.store,
|
|
157
|
-
register(action, fn) {
|
|
154
|
+
register(action, fn) {
|
|
155
|
+
const prev = registry.get(action);
|
|
156
|
+
if (prev && prev !== fn)
|
|
157
|
+
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.`);
|
|
158
|
+
registry.set(action, fn);
|
|
159
|
+
},
|
|
160
|
+
async park(p) {
|
|
161
|
+
const argsSig = signArgs(p, ctrl.agentKey, ctrl.webhookSecret);
|
|
162
|
+
if (!argsSig)
|
|
163
|
+
console.warn(`[decidio] parking ${p.decisionId} UNBOUND — no agent key or webhook secret to sign the payload with, so a modified pending file could not be detected. Run \`npx @decidio/sdk init\` to configure keys.`);
|
|
164
|
+
await ctrl.store.put({ ...p, createdAt: new Date().toISOString(), ...(argsSig ? { argsSig } : {}) });
|
|
165
|
+
},
|
|
158
166
|
async resolve({ decisionId, verdict, reason }) {
|
|
159
167
|
const finish = (o) => { if (o.status !== "unknown")
|
|
160
168
|
opts.onResolved?.(o); return o; };
|
|
161
169
|
const pending = await ctrl.store.get(decisionId);
|
|
162
170
|
if (!pending)
|
|
163
|
-
return { status: "unknown", decisionId };
|
|
171
|
+
return { status: "unknown", decisionId };
|
|
164
172
|
if (verdict === "rejected") {
|
|
165
173
|
await ctrl.store.delete(decisionId);
|
|
166
174
|
return finish({ status: "rejected", decisionId, action: pending.action, reason });
|
|
@@ -169,28 +177,59 @@ export function createDecidioResume(opts) {
|
|
|
169
177
|
await ctrl.store.delete(decisionId);
|
|
170
178
|
return finish({ status: "blocked", decisionId, action: pending.action, reason });
|
|
171
179
|
}
|
|
172
|
-
// Fail CLOSED on any unexpected verdict — never execute on a garbage/unknown value.
|
|
173
180
|
if (verdict !== "approved" && verdict !== "auto_approved")
|
|
174
181
|
return { status: "unknown", decisionId };
|
|
182
|
+
if (pending.decisionId !== decisionId) {
|
|
183
|
+
console.error(`[decidio] REFUSING to execute ${decisionId}: the parked entry in this slot belongs to a DIFFERENT decision (${pending.decisionId}) — the pending file was moved or swapped. Delete it and re-run the action.`);
|
|
184
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload decision-id mismatch" });
|
|
185
|
+
}
|
|
186
|
+
const strictBinding = !!ctrl.agentKey;
|
|
187
|
+
const integrity = verifyArgs(pending, ctrl.agentKey, ctrl.webhookSecret);
|
|
188
|
+
if (integrity === "mismatch") {
|
|
189
|
+
console.error(`[decidio] REFUSING to execute ${decisionId}: the parked payload FAILED its integrity check — the arguments on disk are not the arguments that were routed for approval. The pending file is untouched for inspection; delete it and re-run the action to request a fresh approval.`);
|
|
190
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload integrity check failed" });
|
|
191
|
+
}
|
|
192
|
+
if (integrity === "unverifiable") {
|
|
193
|
+
console.error(`[decidio] REFUSING to execute ${decisionId}: the parked payload is signed but this process lacks the key material to verify it (DECIDIO_AGENT_KEY / DECIDIO_WEBHOOK_SECRET). Restore the agent's env and retry.`);
|
|
194
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unverifiable" });
|
|
195
|
+
}
|
|
196
|
+
if (integrity === "unbound" && strictBinding) {
|
|
197
|
+
console.error(`[decidio] REFUSING to execute ${decisionId}: the parked payload has NO integrity signature, but this agent has keys and would have signed it — a missing signature means the file was tampered with. Delete it and re-run the action.`);
|
|
198
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: "parked payload unsigned (expected a signature)" });
|
|
199
|
+
}
|
|
200
|
+
if (integrity === "unbound")
|
|
201
|
+
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.`);
|
|
175
202
|
const fn = registry.get(pending.action);
|
|
176
|
-
if (!fn)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
203
|
+
if (!fn) {
|
|
204
|
+
console.error(`[decidio] cannot resume ${decisionId}: no function is registered for action "${pending.action}" in THIS process.`);
|
|
205
|
+
console.error(` A worker only re-executes actions registered where it runs. At startup, either declare the action on the wrapper:`);
|
|
206
|
+
console.error(` const doIt = guard.protect(doItRaw, describe, { action: "${pending.action}" });`);
|
|
207
|
+
console.error(` or register the raw function directly:`);
|
|
208
|
+
console.error(` guard.register("${pending.action}", doItRaw);`);
|
|
209
|
+
console.error(` then start the worker. The approved action stays parked until then — nothing is lost.`);
|
|
210
|
+
return finish({ status: "no_handler", decisionId, action: pending.action, reason: `no fn registered for action "${pending.action}" in this process — declare { action } on protect(), or call guard.register("${pending.action}", fn) at startup` });
|
|
211
|
+
}
|
|
180
212
|
const claimed = ctrl.store.claim ? await ctrl.store.claim(decisionId) : (!inFlight.has(decisionId) && (inFlight.add(decisionId), true));
|
|
181
213
|
if (!claimed)
|
|
182
|
-
return { status: "unknown", decisionId };
|
|
214
|
+
return { status: "unknown", decisionId };
|
|
183
215
|
try {
|
|
184
|
-
// Approved: the AGENT performs its own action FIRST. On SUCCESS clear the parked
|
|
185
|
-
// entry (single-use) + confirm. If it throws, RELEASE the claim so the entry stays
|
|
186
|
-
// parked + retryable — an approved action is never lost.
|
|
187
216
|
const result = await fn(...pending.args);
|
|
188
|
-
|
|
217
|
+
let confirmed = true;
|
|
189
218
|
try {
|
|
190
219
|
await confirm(ctrl, decisionId, result);
|
|
191
220
|
}
|
|
192
221
|
catch (e) {
|
|
193
|
-
|
|
222
|
+
confirmed = false;
|
|
223
|
+
console.warn(`[decidio] resume confirm failed (your action DID succeed): ${e.message}`);
|
|
224
|
+
}
|
|
225
|
+
const retainedIsSafe = typeof ctrl.store.claim === "function";
|
|
226
|
+
if (confirmed || !retainedIsSafe)
|
|
227
|
+
await ctrl.store.delete(decisionId);
|
|
228
|
+
if (!confirmed) {
|
|
229
|
+
if (retainedIsSafe)
|
|
230
|
+
console.warn(` The parked entry for ${decisionId} is kept as local evidence that it executed — it stays CLAIMED, so it cannot re-run. Retry the confirmation, then delete it.`);
|
|
231
|
+
else
|
|
232
|
+
console.warn(` This store provides no claim() primitive, so the entry was DELETED to guarantee the action cannot execute twice. ${decisionId} executed, but its confirmation never reached Decidio — re-confirm it from your own records.`);
|
|
194
233
|
}
|
|
195
234
|
return finish({ status: "executed", decisionId, action: pending.action, result });
|
|
196
235
|
}
|
|
@@ -213,9 +252,6 @@ export function createDecidioResume(opts) {
|
|
|
213
252
|
req.on("data", (c) => (raw += c));
|
|
214
253
|
req.on("end", async () => {
|
|
215
254
|
try {
|
|
216
|
-
// Fail CLOSED when no secret is configured: an agent that forgot to set a
|
|
217
|
-
// webhookSecret must NOT be drivable by forged callbacks. Opt out only with
|
|
218
|
-
// allowUnsigned (trusted network boundary).
|
|
219
255
|
if (!secret) {
|
|
220
256
|
if (!opts.allowUnsigned) {
|
|
221
257
|
res.statusCode = 401;
|
|
@@ -282,8 +318,6 @@ export function createDecidioResume(opts) {
|
|
|
282
318
|
st.state === "blocked" ? "blocked" : null;
|
|
283
319
|
if (!verdict)
|
|
284
320
|
continue;
|
|
285
|
-
// A throwing resolve (the agent's own fn failed) must NOT kill the watcher —
|
|
286
|
-
// the entry stays parked and is retried on the next poll.
|
|
287
321
|
try {
|
|
288
322
|
const outcome = await ctrl.resolve({ decisionId: p.decisionId, verdict, reason: st.reason });
|
|
289
323
|
workerOpts?.onResolved?.(outcome);
|
package/dist/signing.d.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/** Sorted-key JCS — must match apps/api/src/canonical.ts byte-for-byte. */
|
|
2
1
|
export declare function canonicalize(v: unknown): string;
|
|
3
|
-
/** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
|
|
4
|
-
* and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
|
|
5
|
-
* it never goes back to Decidio). */
|
|
6
2
|
export declare function generateKeypair(): {
|
|
7
3
|
did: string;
|
|
8
4
|
privateKeyBase64: string;
|
|
@@ -21,20 +17,12 @@ export interface RequesterIdentity {
|
|
|
21
17
|
signature: string;
|
|
22
18
|
};
|
|
23
19
|
}
|
|
24
|
-
/** Sign the CONFIRM call (bound to {action:"confirm", scope:decisionId}) so the gate can
|
|
25
|
-
* prove the caller is the receipt's original requester before granting the wrapper tier
|
|
26
|
-
* (application_confirmed). Returns undefined when no signing key is configured (the server
|
|
27
|
-
* then caps the report at agent_asserted). Used by EVERY confirm path — inline, durable
|
|
28
|
-
* resume, and the engine adapters — so the production paths confirm with a proof too. Also
|
|
29
|
-
* binds `workspaceId` (D-B3.6) when configured, so the confirm proof is tenant-scoped too. */
|
|
30
20
|
export declare function signConfirm(decisionId: string, cfg: {
|
|
31
21
|
agentId?: string;
|
|
32
22
|
agentKey?: string;
|
|
33
23
|
agentDid?: string;
|
|
34
24
|
workspaceId?: string;
|
|
35
25
|
}): RequesterIdentity | undefined;
|
|
36
|
-
/** Sign a request-bound identity proof. The returned object is sent verbatim as the
|
|
37
|
-
* `requesterIdentity` field of /agent/authorize. */
|
|
38
26
|
export declare function signBoundProof(privateKeyBase64: string, fields: {
|
|
39
27
|
agentId: string;
|
|
40
28
|
did: string;
|
package/dist/signing.js
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
// Client-side agent identity (§13 P0-8). The agent generates its Ed25519 keypair LOCALLY
|
|
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
1
|
import { generateKeyPairSync, createPrivateKey, sign as edSign, randomUUID } from "node:crypto";
|
|
10
|
-
// base58btc (Bitcoin alphabet) — for the did:key multibase 'z' prefix.
|
|
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,6 @@ 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
|
-
/** Generate a fresh Ed25519 keypair LOCALLY. Returns the public `did:key` (register this)
|
|
47
|
-
* and the PKCS8 private key base64 (keep this in the agent host's env as DECIDIO_AGENT_KEY —
|
|
48
|
-
* it never goes back to Decidio). */
|
|
49
36
|
export function generateKeypair() {
|
|
50
37
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
51
38
|
const jwk = publicKey.export({ format: "jwk" });
|
|
@@ -54,27 +41,15 @@ export function generateKeypair() {
|
|
|
54
41
|
const privateKeyBase64 = privateKey.export({ format: "der", type: "pkcs8" }).toString("base64");
|
|
55
42
|
return { did, privateKeyBase64 };
|
|
56
43
|
}
|
|
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
44
|
export function signConfirm(decisionId, cfg) {
|
|
64
45
|
if (!cfg.agentKey || !cfg.agentDid || !cfg.agentId)
|
|
65
46
|
return undefined;
|
|
66
47
|
return signBoundProof(cfg.agentKey, { agentId: cfg.agentId, did: cfg.agentDid, action: "confirm", amount: 0, scope: decisionId, workspaceId: cfg.workspaceId });
|
|
67
48
|
}
|
|
68
|
-
/** Sign a request-bound identity proof. The returned object is sent verbatim as the
|
|
69
|
-
* `requesterIdentity` field of /agent/authorize. */
|
|
70
49
|
export function signBoundProof(privateKeyBase64, fields) {
|
|
71
50
|
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, "base64"), format: "der", type: "pkcs8" });
|
|
72
51
|
const nonce = fields.nonce ?? randomUUID();
|
|
73
52
|
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
53
|
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
54
|
const payload = Buffer.from(canonicalize(payloadObj), "utf8").toString("base64url");
|
|
80
55
|
const protectedHdr = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: fields.did }), "utf8").toString("base64url");
|
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.7
|
|
9
9
|
description: >
|
|
10
10
|
Authorize an AI-agent action (proceed | route | block), confirm the captured outcome, and
|
|
11
11
|
receive the signed resume webhook when a routed action is decided. Every outcome seals a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
|
@@ -82,5 +82,9 @@
|
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
84
|
"@decidio/verify": "^0.1.3"
|
|
85
|
+
},
|
|
86
|
+
"author": "OmniTwin Technologies Inc.",
|
|
87
|
+
"bugs": {
|
|
88
|
+
"url": "https://decidioai.com/support/"
|
|
85
89
|
}
|
|
86
90
|
}
|