@tenuo/core 0.3.1-beta.0 → 0.3.3-beta.0

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/dist/client.js CHANGED
@@ -3,12 +3,40 @@ import { randomBytes } from "node:crypto";
3
3
  import { parseConnectToken } from "./connect.js";
4
4
  import { generateIdentity, identityFromKey } from "./identity.js";
5
5
  import { createMcp, presentCall, verifyPresented } from "./mcp.js";
6
+ import { consumeApprovals, snapshotApprovals } from "./replay.js";
6
7
  import { collectReceipt, emitIsolatedReceipt, inheritSessionCollector } from "./receipts.js";
7
8
  import { createRuntime } from "./runtime.js";
8
9
  import { AuthorizationDeniedError, ApprovalRequiredError, TenuoConfigurationError, TenuoError } from "./errors.js";
9
10
  import { Session, isSession, nativeSession } from "./session.js";
10
11
  import { createDevContext, createIssuerContext, createVerifierContext, importSessionFromChain, importSessionFromWire, loadWasm, protocolLimits, publicKeyHexFromHolderKey, } from "./wasm.js";
11
12
  const currentSession = new AsyncLocalStorage();
13
+ const TOOL_POLICY_KEYS = ["allow", "capability", "nonceStore", "onNonceStoreError"];
14
+ const EXECUTE_OPTION_KEYS = ["session", "approvals", "onReceipt", "requestId"];
15
+ const SESSION_INPUT_KEYS = [
16
+ "allow",
17
+ "tools",
18
+ "ttlSeconds",
19
+ "requireApproval",
20
+ "holder",
21
+ "maxDepth",
22
+ "kind",
23
+ "clearance",
24
+ "sessionId",
25
+ "agentId",
26
+ "issuableTools",
27
+ "constraintBounds",
28
+ "maxIssueDepth",
29
+ ];
30
+ const ISSUE_INPUT_KEYS = [
31
+ "allow",
32
+ "holder",
33
+ "ttlSeconds",
34
+ "maxDepth",
35
+ "clearance",
36
+ "sessionId",
37
+ "agentId",
38
+ "requireApproval",
39
+ ];
12
40
  const toolPolicies = new WeakMap();
13
41
  const wrappedInners = new WeakSet();
14
42
  const CLEARANCE_NAMES = new Set(["untrusted", "external", "partner", "internal", "privileged", "system"]);
@@ -226,7 +254,12 @@ class TenuoClient {
226
254
  this.canMint = false;
227
255
  }
228
256
  if (options.revocationList !== undefined) {
229
- this.context.loadRevocationList(normalizeWireBytes(options.revocationList));
257
+ try {
258
+ this.context.loadRevocationList(normalizeWireBytes(options.revocationList));
259
+ }
260
+ catch (error) {
261
+ throw revocationLoadError(error);
262
+ }
230
263
  }
231
264
  this.decide = (decision, tool, native, args) => {
232
265
  this.applyDecision(decision, tool, native, args);
@@ -255,14 +288,20 @@ class TenuoClient {
255
288
  if (toolPolicies.has(inner) || wrappedInners.has(inner)) {
256
289
  throw new TenuoConfigurationError("tenuo.tool() already wrapped this tool");
257
290
  }
291
+ const { nonceStore, onNonceStoreError } = validateToolPolicy(policy);
258
292
  const capability = capabilityName(inner, policy);
259
293
  const original = inner.execute;
260
294
  const execute = async (args, callOptions) => {
295
+ assertExecuteOptions(callOptions);
261
296
  const session = resolveSession(callOptions);
262
297
  const native = nativeSession(session);
263
- const decision = this.context.authorize(native, capability, args, approvalsFrom(callOptions), policy.allow, requestIdFrom(callOptions));
298
+ const approvals = snapshotApprovals(approvalsFrom(callOptions));
299
+ const decision = this.context.authorize(native, capability, args, approvals, policy.allow, requestIdFrom(callOptions));
264
300
  emitReceipt(callOptions, decision.receipt, session, this);
265
301
  if (decision.outcome === "allow") {
302
+ if (nonceStore !== undefined) {
303
+ await consumeApprovals(nonceStore, approvals, this.requestHash(native, capability, decision.args), capability, onNonceStoreError);
304
+ }
266
305
  return original(plainArgs(decision.args), forwardExecuteOptions(callOptions));
267
306
  }
268
307
  this.applyDecision(decision, capability, native, plainArgs(args));
@@ -275,7 +314,20 @@ class TenuoClient {
275
314
  toolPolicies.set(wrapped, { capability, allow: { ...policy.allow } });
276
315
  return wrapped;
277
316
  }
317
+ /** The approval request hash for this call; approvals are bound to it. */
318
+ requestHash(native, tool, args) {
319
+ try {
320
+ return this.context.approvalRequest(native, tool, plainArgs(args)).request_hash;
321
+ }
322
+ catch (error) {
323
+ throw new TenuoConfigurationError(`could not bind approvals to this call: ${errorMessage(error)}`);
324
+ }
325
+ }
278
326
  session(input) {
327
+ if (input === null || typeof input !== "object") {
328
+ throw new TenuoConfigurationError("tenuo.session() requires an options object");
329
+ }
330
+ assertKnownInputKeys(input, SESSION_INPUT_KEYS, "tenuo.session()");
279
331
  const allow = collectSessionAllow(input);
280
332
  const kind = input.kind ?? "execution";
281
333
  if (kind !== "execution" && kind !== "issuer") {
@@ -289,7 +341,7 @@ class TenuoClient {
289
341
  }
290
342
  const options = { kind, allow };
291
343
  if (input.ttlSeconds !== undefined) {
292
- options.ttlSeconds = requireTtl(input.ttlSeconds, "session().ttlSeconds");
344
+ options.ttlSeconds = requireTtl(input.ttlSeconds, "session().ttlSeconds", 1);
293
345
  }
294
346
  if (input.holder !== undefined) {
295
347
  options.holder = requirePublicKey(input.holder, "session().holder").hex;
@@ -332,6 +384,7 @@ class TenuoClient {
332
384
  if (input === null || typeof input !== "object" || input.allow === undefined || Object.keys(input.allow).length === 0) {
333
385
  throw new TenuoConfigurationError("issue() requires at least one capability in allow");
334
386
  }
387
+ assertKnownInputKeys(input, ISSUE_INPUT_KEYS, "issue()");
335
388
  const options = {
336
389
  allow: input.allow,
337
390
  holder: requirePublicKey(input.holder, "issue().holder").hex,
@@ -374,6 +427,10 @@ class TenuoClient {
374
427
  if (typeof input.warrant === "string" && input.warrant.trim().length === 0) {
375
428
  throw new TenuoConfigurationError("sessionFromWire() requires a warrant");
376
429
  }
430
+ // Warrant strings are hex or base64; none of these characters occur in one.
431
+ if (typeof input.warrant === "string" && /^\[|[\s,.;]/.test(input.warrant.trim())) {
432
+ throw new TenuoConfigurationError("sessionFromWire() got a string that looks like several warrant tokens joined together. Pass the array from session.toWire(), or the single string from session.toWireString().");
433
+ }
377
434
  try {
378
435
  if (typeof input.warrant === "string") {
379
436
  return new Session(importSessionFromWire(input.warrant, input.holderKey));
@@ -401,6 +458,12 @@ class TenuoClient {
401
458
  throw new TenuoConfigurationError("narrow() requires a Tenuo Session, not a plain object");
402
459
  }
403
460
  const native = options === undefined ? undefined : narrowOptionsJson(options);
461
+ if (native?.minApprovals !== undefined) {
462
+ const parentMin = session.inspect().minApprovals;
463
+ if (parentMin !== undefined && native.minApprovals < parentMin) {
464
+ throw new TenuoConfigurationError(`narrow().minApprovals ${native.minApprovals} is below the parent's ${parentMin}. The approval threshold can only go up.`);
465
+ }
466
+ }
404
467
  try {
405
468
  const child = new Session(this.context.narrow(nativeSession(session), allow, native));
406
469
  inheritSessionCollector(session, child);
@@ -521,7 +584,12 @@ class TenuoClient {
521
584
  }
522
585
  }
523
586
  revoke(list) {
524
- this.context.loadRevocationList(normalizeWireBytes(list));
587
+ try {
588
+ this.context.loadRevocationList(normalizeWireBytes(list));
589
+ }
590
+ catch (error) {
591
+ throw revocationLoadError(error);
592
+ }
525
593
  }
526
594
  ready() {
527
595
  loadWasm();
@@ -533,6 +601,100 @@ function requireLabel(value, name) {
533
601
  }
534
602
  return value;
535
603
  }
604
+ function validateToolPolicy(policy) {
605
+ if (policy === null || typeof policy !== "object" || Array.isArray(policy)) {
606
+ throw new TenuoConfigurationError("tenuo.tool() requires a policy object: { allow, capability? }");
607
+ }
608
+ assertKnownInputKeys(policy, TOOL_POLICY_KEYS, "tenuo.tool() policy");
609
+ const record = policy;
610
+ if (record.allow === undefined) {
611
+ throw new TenuoConfigurationError("tenuo.tool() policy requires allow: name each argument with a constraint, or pass {} for no host ceiling");
612
+ }
613
+ if (record.allow === null || typeof record.allow !== "object" || Array.isArray(record.allow)) {
614
+ throw new TenuoConfigurationError("tenuo.tool() policy.allow must be an object of per-argument constraints");
615
+ }
616
+ const out = {};
617
+ if (record.nonceStore !== undefined) {
618
+ const store = record.nonceStore;
619
+ if (store === null || typeof store !== "object" || typeof store.checkAndRecord !== "function") {
620
+ throw new TenuoConfigurationError("tenuo.tool() policy.nonceStore must implement checkAndRecord()");
621
+ }
622
+ out.nonceStore = record.nonceStore;
623
+ }
624
+ if (record.onNonceStoreError !== undefined) {
625
+ if (typeof record.onNonceStoreError !== "function") {
626
+ throw new TenuoConfigurationError("tenuo.tool() policy.onNonceStoreError must be a function");
627
+ }
628
+ out.onNonceStoreError = record.onNonceStoreError;
629
+ }
630
+ return out;
631
+ }
632
+ function assertKnownInputKeys(value, known, label) {
633
+ for (const key of Object.keys(value)) {
634
+ if (!known.includes(key)) {
635
+ throw new TenuoConfigurationError(`${label}: unknown key '${key}' (expected ${known.join(", ")})`);
636
+ }
637
+ }
638
+ }
639
+ /**
640
+ * Execute options are shared with the wrapped tool, so unknown keys pass
641
+ * through. A key that is a near miss of a Tenuo option is a typo that would
642
+ * otherwise drop that option silently (`sesion` falls back to the ambient
643
+ * session), so it throws.
644
+ */
645
+ function assertExecuteOptions(callOptions) {
646
+ if (callOptions === null || typeof callOptions !== "object") {
647
+ return;
648
+ }
649
+ for (const key of Object.keys(callOptions)) {
650
+ if (EXECUTE_OPTION_KEYS.includes(key)) {
651
+ continue;
652
+ }
653
+ const lower = key.toLowerCase();
654
+ for (const known of EXECUTE_OPTION_KEYS) {
655
+ const target = known.toLowerCase();
656
+ const limit = known.length <= 7 ? 1 : 2;
657
+ if (lower === target || editDistance(lower, target) <= limit) {
658
+ throw new TenuoConfigurationError(`execute() options: unknown key '${key}'. Did you mean '${known}'? Tenuo options are ${EXECUTE_OPTION_KEYS.join(", ")}.`);
659
+ }
660
+ }
661
+ }
662
+ }
663
+ /** Optimal string alignment distance (adjacent transpositions count once). */
664
+ function editDistance(a, b) {
665
+ if (Math.abs(a.length - b.length) > 2) {
666
+ return 3;
667
+ }
668
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
669
+ for (let i = 1; i <= a.length; i += 1) {
670
+ for (let j = 1; j <= b.length; j += 1) {
671
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
672
+ let best = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
673
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
674
+ best = Math.min(best, d[i - 2][j - 2] + 1);
675
+ }
676
+ d[i][j] = best;
677
+ }
678
+ }
679
+ return d[a.length][b.length];
680
+ }
681
+ function revocationLoadError(error) {
682
+ if (error instanceof TenuoError) {
683
+ return error;
684
+ }
685
+ // Strip the prefix the core adds so the code reflects the actual cause.
686
+ const message = errorMessage(error).replace(/^TENUO_[A-Z_]+:\s*/, "");
687
+ if (/rollback/i.test(message)) {
688
+ return new TenuoConfigurationError(message);
689
+ }
690
+ if (/issuer does not match|untrusted|not trusted/i.test(message)) {
691
+ return new TenuoConfigurationError(message, "TENUO_UNTRUSTED_ROOT");
692
+ }
693
+ if (/signature/i.test(message)) {
694
+ return new TenuoConfigurationError(message, "TENUO_SIGNATURE_INVALID");
695
+ }
696
+ return new TenuoConfigurationError(message);
697
+ }
536
698
  function errorMessage(error) {
537
699
  return error instanceof Error ? error.message : String(error);
538
700
  }
@@ -781,6 +943,9 @@ function collectSessionAllow(input) {
781
943
  if (policy === undefined) {
782
944
  throw new TenuoConfigurationError("session({ tools }) requires tools from tenuo.tool()");
783
945
  }
946
+ if (Object.keys(policy.allow).length === 0 && !Object.prototype.hasOwnProperty.call(input.allow ?? {}, policy.capability)) {
947
+ throw new TenuoConfigurationError(`session({ tools }): ${policy.capability} has allow: {}, which would put a capability that accepts any arguments in the warrant. Name its arguments in tenuo.tool({ allow }), or opt in explicitly with session({ tools, allow: { ${policy.capability}: {} } }).`);
948
+ }
784
949
  const existing = allow[policy.capability];
785
950
  if (existing !== undefined && !sameAllow(existing, policy.allow)) {
786
951
  throw new TenuoConfigurationError(`session() allow and tools disagree on ${policy.capability}`);
@@ -835,10 +1000,9 @@ export function signApproval(request, approverSecret, options) {
835
1000
  }
836
1001
  /** Decode an approval envelope. Not authorization: the call path still decides. */
837
1002
  export function inspectApproval(envelope) {
838
- const text = typeof envelope === "string" ? envelope : Buffer.from(envelope).toString("base64");
839
1003
  let raw;
840
1004
  try {
841
- raw = loadWasm().sdkInspectApproval(text);
1005
+ raw = loadWasm().sdkInspectApproval(envelope);
842
1006
  }
843
1007
  catch (error) {
844
1008
  throw new TenuoConfigurationError(errorMessage(error));