@prismnetwork/agent-sdk 0.7.10 → 0.7.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/decision.mjs ADDED
@@ -0,0 +1,137 @@
1
+ /// Authorise a spend before it happens, and bind the reason to the lease.
2
+ ///
3
+ /// An agent with a wallet funds an escrow on its own judgement. The operator who
4
+ /// owns that wallet gets the bill and, today, no answer to "why did it spend
5
+ /// this". A log written afterwards by the process that spent the money is worth
6
+ /// what that process says it is worth.
7
+ ///
8
+ /// So the reason is decided first, checked against a policy the caller wrote,
9
+ /// and hashed into the one field the escrow already commits on chain. A lease
10
+ /// that was not authorised is never funded, and one that was carries a
11
+ /// reference nobody can recompute without producing the decision behind it.
12
+ ///
13
+ /// What this proves: a specific decision existed before the deposit was
14
+ /// broadcast, and the caller's own thresholds admitted it. What it does not: any
15
+ /// claim that the decision was sound. A confidently wrong judgement binds
16
+ /// exactly as well as a correct one.
17
+ ///
18
+ /// The decision can come from anywhere. A System One model returning calibrated
19
+ /// probabilities, a general model, or a rule in the caller's own code all
20
+ /// produce the same record, and none of them is a dependency of this SDK.
21
+ /// Nothing but a hash leaves the caller's process.
22
+ import { keccak256, stringToBytes, concatBytes, hexToBytes } from "viem";
23
+
24
+ const NAME = /^[a-z][a-z0-9_]{0,63}$/;
25
+
26
+ export class DecisionRefused extends Error {
27
+ constructor(reasons) {
28
+ super(reasons.join("; "));
29
+ this.name = "DecisionRefused";
30
+ this.reasons = reasons;
31
+ }
32
+ }
33
+
34
+ /// One typed answer. `value` is the selected option, the score, or the
35
+ /// probability that a yes/no question is yes. `confidence` is how sure the
36
+ /// source is that the value is right, which is a different axis and is usually
37
+ /// what a spending threshold should read.
38
+ export function answer(name, value, confidence = null) {
39
+ if (!NAME.test(name)) throw new Error(`answer name ${JSON.stringify(name)} is not a lowercase identifier`);
40
+ if (confidence !== null && !(confidence >= 0 && confidence <= 1)) {
41
+ throw new Error(`${name}: confidence ${confidence} is outside 0..1`);
42
+ }
43
+ return { name, value, confidence };
44
+ }
45
+
46
+ /// Why a spend is about to happen. `source` names what judged, for example
47
+ /// "jev-1.13.0" or "policy:cpu-first". It is recorded rather than trusted: this
48
+ /// SDK cannot tell a model's answer from one a caller typed, and does not
49
+ /// pretend to.
50
+ export function decision({ action, source, answers = [], policyId = null }) {
51
+ if (!action?.trim()) throw new Error("a decision needs an action");
52
+ if (!source?.trim()) throw new Error("a decision needs a source, so the record says what judged");
53
+ const seen = new Set();
54
+ for (const a of answers) {
55
+ if (seen.has(a.name)) throw new Error(`duplicate answer ${JSON.stringify(a.name)}`);
56
+ seen.add(a.name);
57
+ }
58
+ return { action, source, answers, policyId };
59
+ }
60
+
61
+ /// The exact bytes that get hashed. Sorted by name and separator-pinned, so the
62
+ /// same decision produces the same reference in any language. The Python SDK
63
+ /// builds this byte for byte; a cross-language mismatch here would make a lease
64
+ /// funded by one unverifiable by the other.
65
+ export function canonical(d) {
66
+ return stringToBytes(JSON.stringify({
67
+ action: d.action,
68
+ source: d.source,
69
+ policy_id: d.policyId ?? null,
70
+ answers: [...d.answers]
71
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
72
+ .map((a) => ({ name: a.name, value: a.value, confidence: a.confidence ?? null })),
73
+ }));
74
+ }
75
+
76
+ /// Every reason this decision is not authorised. Empty means it is.
77
+ export function refusals(policy, d) {
78
+ const reasons = [];
79
+ const allow = policy.allow ?? [];
80
+ if (allow.length && !allow.includes(d.action)) {
81
+ reasons.push(
82
+ `action ${JSON.stringify(d.action)} is not in the policy's allowed set (${[...allow].sort().join(", ")})`,
83
+ );
84
+ }
85
+ if (d.policyId != null && d.policyId !== policy.policyId) {
86
+ reasons.push(`decision cites policy ${JSON.stringify(d.policyId)}, this is ${JSON.stringify(policy.policyId)}`);
87
+ }
88
+ for (const name of [...(policy.require ?? [])].sort()) {
89
+ if (!d.answers.some((a) => a.name === name)) {
90
+ reasons.push(`policy requires an answer for ${JSON.stringify(name)} and none was given`);
91
+ }
92
+ }
93
+ for (const [name, floor] of Object.entries(policy.minimums ?? {}).sort()) {
94
+ const a = d.answers.find((x) => x.name === name);
95
+ if (!a) reasons.push(`policy sets a floor for ${JSON.stringify(name)} and no answer was given`);
96
+ else if (a.confidence == null) {
97
+ reasons.push(`${JSON.stringify(name)} carries no confidence, so the ${floor} floor cannot be met`);
98
+ } else if (a.confidence < floor) {
99
+ reasons.push(`${JSON.stringify(name)} confidence ${a.confidence} is under the ${floor} floor`);
100
+ }
101
+ }
102
+ return reasons;
103
+ }
104
+
105
+ /// Return the decision, or throw before anything is signed.
106
+ export function authorise(policy, d) {
107
+ const reasons = refusals(policy, d);
108
+ if (reasons.length) throw new DecisionRefused(reasons);
109
+ return d;
110
+ }
111
+
112
+ /// The bytes32 the escrow records for this lease.
113
+ ///
114
+ /// Without a decision this is what it has always been, the hash of the quote id,
115
+ /// so an unauthorised caller's leases stay byte-identical to before.
116
+ ///
117
+ /// With one, the quote id is hashed together with the decision's digest. The
118
+ /// escrow refuses a reference it has already seen, so the quote id has to stay
119
+ /// in the preimage: two runs of the same decision are two different leases and
120
+ /// must not collide.
121
+ export function leaseReference(quoteId, d) {
122
+ const quoteDigest = keccak256(stringToBytes(quoteId));
123
+ if (!d) return quoteDigest;
124
+ return keccak256(concatBytes([hexToBytes(quoteDigest), hexToBytes(keccak256(canonical(d)))]));
125
+ }
126
+
127
+ /// The hex digest the control plane needs to know which derivation to expect.
128
+ /// It is the decision's hash, not the decision: the service can check that the
129
+ /// funding log commits to something, and cannot read what that something says.
130
+ export function decisionDigest(d) {
131
+ return d ? keccak256(canonical(d)) : null;
132
+ }
133
+
134
+ /// Whether this decision is the one that funded that lease.
135
+ export function referenceMatches(quoteId, d, reference) {
136
+ return leaseReference(quoteId, d).toLowerCase() === String(reference).toLowerCase();
137
+ }
package/e2ee.mjs CHANGED
@@ -156,7 +156,12 @@ export function encryptChatRequest(body, keyset, { now = Math.floor(Date.now() /
156
156
  if (typeof message?.content !== "string") {
157
157
  // Any plaintext string at a protected path fails the request upstream, so
158
158
  // a body this client cannot fully protect is refused here instead.
159
- throw new E2eeError(`messages.${index}.content must be a string to be encrypted`);
159
+ throw new E2eeError(
160
+ `messages.${index}.content is not a string, and the sealed envelope covers string ` +
161
+ "content only: the protocol binds each ciphertext to the field path " +
162
+ "messages.N.content, which a content-parts array has no equivalent of. An image " +
163
+ "message can still be served in the enclave with e2ee off, where the relay sees it.",
164
+ );
160
165
  }
161
166
  const field = `messages.${index}.content`;
162
167
  const aad = requestAad({ algo: serviceKey.algo, model: body.model, field, nonce, ts });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prismnetwork/agent-sdk",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "description": "Headless GPU leasing and renter-encrypted storage on Prism Network for wallet-holding agents.",
5
5
  "type": "module",
6
6
  "main": "prism.mjs",
@@ -9,6 +9,9 @@
9
9
  "types": "./prism.d.mts",
10
10
  "default": "./prism.mjs"
11
11
  },
12
+ "./decision": {
13
+ "default": "./decision.mjs"
14
+ },
12
15
  "./attest": {
13
16
  "types": "./attest.d.mts",
14
17
  "default": "./attest.mjs"
@@ -39,6 +42,7 @@
39
42
  "prism.d.mts",
40
43
  "attest.mjs",
41
44
  "attest.d.mts",
45
+ "decision.mjs",
42
46
  "e2ee.mjs",
43
47
  "e2ee.d.mts",
44
48
  "hostkey.mjs",
package/prism.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // Prism Network agent SDK: headless GPU leasing for wallet-holding agents.
2
2
  // No browser, no Privy. Authenticate with a wallet signature, pay on-chain, run.
3
3
  import { execFileSync, spawn } from "node:child_process";
4
+ import { authorise, decisionDigest, leaseReference } from "./decision.mjs";
4
5
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
6
7
  import { join } from "node:path";
@@ -43,7 +44,6 @@ export const robinhoodChain = defineChain({
43
44
 
44
45
  export const USDG = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168";
45
46
 
46
-
47
47
  // A digest-pinned image. MCP and x402 import this so their default can't drift
48
48
  // from the SDK's.
49
49
  export const DEFAULT_IMAGE =
@@ -275,13 +275,13 @@ export class PrismAgent {
275
275
  return this.#submit(() => this.#fundNow(quote));
276
276
  }
277
277
 
278
- async #fundNow(quote) {
278
+ async #fundNow(quote, decision = null) {
279
279
  if (typeof quote?.quote_id !== "string" || typeof quote?.node_id !== "string") {
280
280
  throw new PrismError(400, "invalid_quote");
281
281
  }
282
282
  const deposit = parseBaseUnits(quote.maximum_escrow, "maximum_escrow");
283
283
  const duration = parseDuration(quote.duration_seconds);
284
- const clientReference = keccak256(stringToBytes(quote.quote_id));
284
+ const clientReference = leaseReference(quote.quote_id, decision);
285
285
  let broadcast = null;
286
286
  try {
287
287
  // Approving and spending are one indivisible step. The approval covers
@@ -334,12 +334,17 @@ export class PrismAgent {
334
334
  }
335
335
  }
336
336
 
337
- async confirm({ quoteId, transactionHash, sshAuthorizedKey }) {
337
+ /// `decisionHash` travels when the lease was authorised: the client
338
+ /// reference on chain is derived from it, and the control plane cannot check
339
+ /// the funding log without knowing which derivation to expect. The digest is
340
+ /// all it gets; the decision stays here.
341
+ async confirm({ quoteId, transactionHash, sshAuthorizedKey, decisionHash = null }) {
338
342
  return this.#proxy("POST", ["leases", "confirm"], {
339
343
  body: {
340
344
  quote_id: quoteId,
341
345
  transaction_hash: transactionHash,
342
346
  ssh_authorized_key: sshAuthorizedKey,
347
+ ...(decisionHash ? { decision_hash: decisionHash } : {}),
343
348
  },
344
349
  });
345
350
  }
@@ -426,7 +431,12 @@ export class PrismAgent {
426
431
  maxDeposit = null,
427
432
  minTrustClass = "open",
428
433
  command = null,
434
+ decision = null,
435
+ policy = null,
429
436
  } = {}) {
437
+ // Checked before a quote is taken, so a refusal costs nothing and does not
438
+ // hold capacity against other renters while it expires.
439
+ if (policy) authorise(policy, decision ?? { action: "unstated", source: "none", answers: [] });
430
440
  if (!this.session) await this.authenticate();
431
441
  // A wallet with no balance at all cannot fund anything, and a doomed quote
432
442
  // still holds capacity against other renters until it expires. Refuse
@@ -463,11 +473,12 @@ export class PrismAgent {
463
473
  if (maxDeposit != null && parseBaseUnits(quote.maximum_escrow, "maximum_escrow") > BigInt(maxDeposit)) {
464
474
  throw new PrismError(402, "cost_exceeds_max", { required: quote.maximum_escrow, max: String(maxDeposit) });
465
475
  }
466
- funded = await this.#fundNow(quote);
476
+ funded = await this.#fundNow(quote, decision);
467
477
  return this.confirm({
468
478
  quoteId: quote.quote_id,
469
479
  transactionHash: funded.hash,
470
480
  sshAuthorizedKey: key.publicKey,
481
+ decisionHash: decisionDigest(decision),
471
482
  });
472
483
  });
473
484
  if (!Number.isInteger(record?.lease_id)) {