@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/LICENSE +76 -61
- package/README.md +20 -9
- package/dist/api.d.ts +46 -6
- package/dist/api.js.map +1 -1
- package/dist/build-info.json +3 -3
- package/dist/client.js +170 -6
- package/dist/client.js.map +1 -1
- package/dist/generated/tenuo_wasm.d.ts +28 -2
- package/dist/generated/tenuo_wasm.js +95 -5
- package/dist/generated/tenuo_wasm_bg.wasm +0 -0
- package/dist/generated/tenuo_wasm_bg.wasm.d.ts +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +47 -110
- package/dist/mcp.js.map +1 -1
- package/dist/replay.d.ts +20 -0
- package/dist/replay.js +117 -0
- package/dist/replay.js.map +1 -0
- package/dist/session.d.ts +1 -0
- package/dist/session.js +11 -0
- package/dist/session.js.map +1 -1
- package/dist/wasm.d.ts +21 -1
- package/dist/wasm.js +12 -0
- package/dist/wasm.js.map +1 -1
- package/package.json +12 -5
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
1005
|
+
raw = loadWasm().sdkInspectApproval(envelope);
|
|
842
1006
|
}
|
|
843
1007
|
catch (error) {
|
|
844
1008
|
throw new TenuoConfigurationError(errorMessage(error));
|