@kya-os/mcp 1.3.2 → 1.5.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/CHANGELOG.md +222 -1
- package/dist/delegation/index.d.ts +2 -1
- package/dist/delegation/index.d.ts.map +1 -1
- package/dist/delegation/index.js +2 -1
- package/dist/delegation/index.js.map +1 -1
- package/dist/delegation/scope-matcher.d.ts +35 -0
- package/dist/delegation/scope-matcher.d.ts.map +1 -0
- package/dist/delegation/scope-matcher.js +83 -0
- package/dist/delegation/scope-matcher.js.map +1 -0
- package/dist/delegation/vc-verifier.d.ts +1 -11
- package/dist/delegation/vc-verifier.d.ts.map +1 -1
- package/dist/delegation/vc-verifier.js +5 -19
- package/dist/delegation/vc-verifier.js.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +1 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/middleware/with-kya-os.d.ts +88 -31
- package/dist/middleware/with-kya-os.d.ts.map +1 -1
- package/dist/middleware/with-kya-os.js +325 -88
- package/dist/middleware/with-kya-os.js.map +1 -1
- package/dist/policy/approval.d.ts +34 -0
- package/dist/policy/approval.d.ts.map +1 -0
- package/dist/policy/approval.js +29 -0
- package/dist/policy/approval.js.map +1 -0
- package/dist/policy/classifier.d.ts +38 -0
- package/dist/policy/classifier.d.ts.map +1 -0
- package/dist/policy/classifier.js +97 -0
- package/dist/policy/classifier.js.map +1 -0
- package/dist/policy/default-engine.d.ts +24 -0
- package/dist/policy/default-engine.d.ts.map +1 -0
- package/dist/policy/default-engine.js +41 -0
- package/dist/policy/default-engine.js.map +1 -0
- package/dist/policy/engine.d.ts +12 -0
- package/dist/policy/engine.d.ts.map +1 -0
- package/dist/policy/engine.js +2 -0
- package/dist/policy/engine.js.map +1 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +7 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/projection.d.ts +47 -0
- package/dist/policy/projection.d.ts.map +1 -0
- package/dist/policy/projection.js +33 -0
- package/dist/policy/projection.js.map +1 -0
- package/dist/policy/types.d.ts +52 -0
- package/dist/policy/types.d.ts.map +1 -0
- package/dist/policy/types.js +10 -0
- package/dist/policy/types.js.map +1 -0
- package/dist/proof/errors.d.ts +1 -0
- package/dist/proof/errors.d.ts.map +1 -1
- package/dist/proof/errors.js +4 -0
- package/dist/proof/errors.js.map +1 -1
- package/dist/proof/generator.d.ts +28 -4
- package/dist/proof/generator.d.ts.map +1 -1
- package/dist/proof/generator.js +48 -20
- package/dist/proof/generator.js.map +1 -1
- package/dist/proof/verifier.d.ts +32 -3
- package/dist/proof/verifier.d.ts.map +1 -1
- package/dist/proof/verifier.js +85 -6
- package/dist/proof/verifier.js.map +1 -1
- package/dist/providers/audit-log.d.ts +62 -0
- package/dist/providers/audit-log.d.ts.map +1 -0
- package/dist/providers/audit-log.js +75 -0
- package/dist/providers/audit-log.js.map +1 -0
- package/dist/providers/index.d.ts +3 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +3 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/runtime-fetch.d.ts +73 -0
- package/dist/providers/runtime-fetch.d.ts.map +1 -0
- package/dist/providers/runtime-fetch.js +169 -0
- package/dist/providers/runtime-fetch.js.map +1 -0
- package/dist/providers/system-clock.d.ts +39 -0
- package/dist/providers/system-clock.d.ts.map +1 -0
- package/dist/providers/system-clock.js +49 -0
- package/dist/providers/system-clock.js.map +1 -0
- package/dist/types/protocol.d.ts +38 -3
- package/dist/types/protocol.d.ts.map +1 -1
- package/dist/types/protocol.js +44 -9
- package/dist/types/protocol.js.map +1 -1
- package/package.json +20 -15
- package/schemas/README.md +1 -1
- package/schemas/delegation-credential.json +2 -2
- package/schemas/detached-proof.json +12 -3
- package/schemas/handshake-request.json +1 -1
- package/schemas/handshake-response.json +1 -1
- package/schemas/well-known-mcpi.json +1 -1
|
@@ -14,51 +14,103 @@
|
|
|
14
14
|
* internally by `withKyaOs()` and for advanced use cases like the
|
|
15
15
|
* low-level `Server` API or custom request handler patterns.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import { RuntimeFetchProvider } from "../providers/runtime-fetch.js";
|
|
18
|
+
import { NoopAuditLogProvider } from "../providers/audit-log.js";
|
|
18
19
|
import { SessionManager, } from "../session/manager.js";
|
|
19
20
|
import { ProofGenerator, } from "../proof/generator.js";
|
|
20
21
|
import { validateHandshakeFormat } from "../session/manager.js";
|
|
21
22
|
import { DelegationCredentialVerifier, } from "../delegation/vc-verifier.js";
|
|
22
23
|
import { createDidKeyResolver } from "../delegation/did-key-resolver.js";
|
|
24
|
+
import { scopeSatisfies } from "../delegation/scope-matcher.js";
|
|
23
25
|
import { createDidWebResolver } from "../delegation/did-web-resolver.js";
|
|
24
26
|
import { verifyDelegationAudience } from "../delegation/audience-validator.js";
|
|
25
|
-
import { createNeedsAuthorizationError, extractDelegationFromVC, } from "../types/protocol.js";
|
|
27
|
+
import { createNeedsAuthorizationError, createNeedsApprovalError, extractDelegationFromVC, } from "../types/protocol.js";
|
|
26
28
|
import { logger } from "../logging/index.js";
|
|
29
|
+
import { RiskClassifier } from "../policy/classifier.js";
|
|
30
|
+
import { DefaultPolicyEngine } from "../policy/default-engine.js";
|
|
31
|
+
import { verifyApprovalQuorum } from "../policy/approval.js";
|
|
32
|
+
import { buildPolicyRequest } from "../policy/projection.js";
|
|
27
33
|
import { KYA_OS_ERROR_CODES } from "../errors.js";
|
|
28
34
|
import { canonicalizeJSON, parseVCJWT } from "../delegation/utils.js";
|
|
29
35
|
import { base64urlDecodeToBytes, base64urlEncodeFromBytes, bytesToBase64 } from "../utils/base64.js";
|
|
30
36
|
export const KYA_OS_ACTIONS = ["handshake", "identity", "reputation"];
|
|
31
|
-
class RuntimeFetchProvider extends FetchProvider {
|
|
32
|
-
async resolveDID() {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
async fetchStatusList() {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
async fetchDelegationChain() {
|
|
39
|
-
return [];
|
|
40
|
-
}
|
|
41
|
-
async fetch(url, options) {
|
|
42
|
-
if (typeof globalThis.fetch !== "function") {
|
|
43
|
-
throw new Error("Global fetch is not available in this runtime");
|
|
44
|
-
}
|
|
45
|
-
return globalThis.fetch(url, options);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
37
|
function getDelegationScopes(credential) {
|
|
49
38
|
const scopes = new Set();
|
|
50
39
|
for (const scope of credential.credentialSubject.delegation.scopes ?? []) {
|
|
51
40
|
scopes.add(scope);
|
|
52
41
|
}
|
|
53
|
-
for (const scope of credential.credentialSubject.delegation.constraints
|
|
42
|
+
for (const scope of credential.credentialSubject.delegation.constraints?.scopes ?? []) {
|
|
54
43
|
scopes.add(scope);
|
|
55
44
|
}
|
|
56
45
|
return Array.from(scopes);
|
|
57
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Strip control characters and cap length on caller-derived values before they
|
|
49
|
+
* are interpolated into client-facing reasons or log lines. A hostile
|
|
50
|
+
* credential could otherwise embed newlines / control chars in an id or scope to
|
|
51
|
+
* forge or corrupt log entries (log injection) or break a terminal. The fixed
|
|
52
|
+
* parts of a reason contain no control chars, so sanitizing the whole assembled
|
|
53
|
+
* string at the emission boundary is equivalent to sanitizing each interpolated
|
|
54
|
+
* value, and is idempotent.
|
|
55
|
+
*/
|
|
56
|
+
function sanitizeForMessage(value, maxLen = 256) {
|
|
57
|
+
const s = typeof value === "string" ? value : String(value);
|
|
58
|
+
// Replace C0/C1 control chars (incl. CR, LF, TAB, ESC, DEL) with U+FFFD so a
|
|
59
|
+
// hostile id/scope cannot forge log lines or break a terminal. Filtered by
|
|
60
|
+
// code point to keep this source ASCII-only (no literal control chars).
|
|
61
|
+
let out = "";
|
|
62
|
+
for (const ch of s) {
|
|
63
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
64
|
+
out += code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? "\uFFFD" : ch;
|
|
65
|
+
}
|
|
66
|
+
return out.length > maxLen ? `${out.slice(0, maxLen)}\u2026` : out;
|
|
67
|
+
}
|
|
68
|
+
/** Best-effort projection of a delegation VC into policy principal facts. */
|
|
69
|
+
function extractPolicyPrincipal(del) {
|
|
70
|
+
if (!del || typeof del !== "object") {
|
|
71
|
+
return { agentDid: "unknown", delegatedScopes: [] };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const vc = del;
|
|
75
|
+
const subject = vc.credentialSubject;
|
|
76
|
+
const agentDid = subject?.delegation?.subjectDid ?? subject?.id ?? "unknown";
|
|
77
|
+
const responsibleParty = subject?.delegation?.controller;
|
|
78
|
+
let delegatedScopes = [];
|
|
79
|
+
try {
|
|
80
|
+
delegatedScopes = getDelegationScopes(vc);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
delegatedScopes = [];
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
agentDid,
|
|
87
|
+
...(responsibleParty ? { responsibleParty } : {}),
|
|
88
|
+
delegatedScopes,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { agentDid: "unknown", delegatedScopes: [] };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
58
95
|
function validateScopeAttenuation(parentCredential, childCredential) {
|
|
59
96
|
const parentScopes = getDelegationScopes(parentCredential);
|
|
60
97
|
const childScopes = getDelegationScopes(childCredential);
|
|
61
98
|
const childDelegation = childCredential.credentialSubject.delegation;
|
|
99
|
+
// CRISP matcher attenuation: getDelegationScopes does NOT see constraints.crisp.scopes,
|
|
100
|
+
// so a re-delegation could otherwise widen authority by introducing a broad
|
|
101
|
+
// prefix/regex matcher (e.g. resource:"" matches every scope). Require the child's
|
|
102
|
+
// crisp matchers to be a subset of the parent's (by matcher+resource). Fail closed.
|
|
103
|
+
const crispKey = (s) => `${s.matcher}\u0000${s.resource}`;
|
|
104
|
+
const parentCrisp = new Set((parentCredential.credentialSubject.delegation.constraints.crisp?.scopes ?? []).map(crispKey));
|
|
105
|
+
const widenedCrisp = (childDelegation.constraints.crisp?.scopes ?? []).filter((s) => !parentCrisp.has(crispKey(s)));
|
|
106
|
+
if (widenedCrisp.length > 0) {
|
|
107
|
+
return {
|
|
108
|
+
valid: false,
|
|
109
|
+
reason: `Delegation ${childDelegation.id} introduces crisp scope matcher(s) absent from parent ${parentCredential.credentialSubject.delegation.id}: ${widenedCrisp
|
|
110
|
+
.map((s) => `${s.matcher}:${s.resource}`)
|
|
111
|
+
.join(", ")}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
62
114
|
if (parentScopes.length === 0) {
|
|
63
115
|
return { valid: true };
|
|
64
116
|
}
|
|
@@ -100,11 +152,6 @@ function validateScopeAttenuation(parentCredential, childCredential) {
|
|
|
100
152
|
* deployments behind a load balancer. For distributed deployments, implement a custom
|
|
101
153
|
* `SessionStore` backed by Redis, DynamoDB, or similar and pass it via `config.session`.
|
|
102
154
|
*/
|
|
103
|
-
// Module-level warn-once flags for unsafe configuration. Per-process,
|
|
104
|
-
// not per-session, so long-running servers don't spam logs on every new
|
|
105
|
-
// session.
|
|
106
|
-
let warnedAudienceOptOut = false;
|
|
107
|
-
let warnedLegacyUnsafeDelegation = false;
|
|
108
155
|
export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
109
156
|
const identity = {
|
|
110
157
|
did: config.identity.did,
|
|
@@ -118,6 +165,7 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
118
165
|
});
|
|
119
166
|
const proofGenerator = new ProofGenerator(identity, cryptoProvider);
|
|
120
167
|
const delegationConfig = config.delegation;
|
|
168
|
+
const auditLog = config.auditLog ?? new NoopAuditLogProvider();
|
|
121
169
|
// Session map: sessionId → last nonce (for proof generation)
|
|
122
170
|
const sessionNonces = new Map();
|
|
123
171
|
// Active session tracking — set after handshake (manual or auto)
|
|
@@ -299,8 +347,8 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
299
347
|
return undefined;
|
|
300
348
|
}
|
|
301
349
|
function wrapWithProof(toolName, handler) {
|
|
302
|
-
return async (args, sessionId) => {
|
|
303
|
-
const result = await handler(args, sessionId);
|
|
350
|
+
return async (args, sessionId, context) => {
|
|
351
|
+
const result = await handler(args, sessionId, context);
|
|
304
352
|
if (result.isError) {
|
|
305
353
|
return result;
|
|
306
354
|
}
|
|
@@ -316,9 +364,29 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
316
364
|
try {
|
|
317
365
|
const request = { method: toolName, params: args };
|
|
318
366
|
const response = { data: result.content };
|
|
319
|
-
const proof = await proofGenerator.generateProof(request, response, session);
|
|
367
|
+
const proof = await proofGenerator.generateProof(request, response, session, { scopeId: context?.scopeId });
|
|
320
368
|
// Attach proof as _meta (rendered by MCP Inspector, invisible to LLMs)
|
|
321
369
|
result._meta = { proof };
|
|
370
|
+
// Hand the verified call to the audit sink. A sink failure MUST NOT
|
|
371
|
+
// break the tool response, so it is logged and swallowed.
|
|
372
|
+
try {
|
|
373
|
+
await auditLog.logAuditRecord({
|
|
374
|
+
identity: { did: identity.did, kid: identity.kid },
|
|
375
|
+
session: { sessionId: session.sessionId, audience: session.audience },
|
|
376
|
+
requestHash: proof.meta.requestHash,
|
|
377
|
+
responseHash: proof.meta.responseHash,
|
|
378
|
+
verified: "yes",
|
|
379
|
+
scopeId: proof.meta.scopeId,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch (auditError) {
|
|
383
|
+
logger.error("[kya-os] Audit log failed", {
|
|
384
|
+
tool: toolName,
|
|
385
|
+
error: auditError instanceof Error
|
|
386
|
+
? auditError.message
|
|
387
|
+
: String(auditError),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
322
390
|
}
|
|
323
391
|
catch (error) {
|
|
324
392
|
logger.error("[kya-os] Proof generation failed", {
|
|
@@ -332,21 +400,59 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
332
400
|
return result;
|
|
333
401
|
};
|
|
334
402
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
403
|
+
/**
|
|
404
|
+
* Attach a signed proof recording an authorization OUTCOME to a response, so
|
|
405
|
+
* rejected or pending privileged attempts leave a verifiable, non-repudiable
|
|
406
|
+
* forensic record (these previously produced no proof). Used for `denied`,
|
|
407
|
+
* `step_up_required`, and `needs_authorization` outcomes.
|
|
408
|
+
*
|
|
409
|
+
* When `responseData` is provided (e.g. the `needs_authorization` challenge
|
|
410
|
+
* content), the proof binds a `responseHash` over it — so the signed proof
|
|
411
|
+
* also attests the response body (notably the consent `authorizationUrl`),
|
|
412
|
+
* letting a verifier detect a tampered/MITM-swapped URL. Pure denials and
|
|
413
|
+
* step-ups pass no `responseData` (there is no response body to bind).
|
|
414
|
+
*
|
|
415
|
+
* Best-effort: if no session can be resolved or proof generation fails, the
|
|
416
|
+
* original response is returned unchanged.
|
|
417
|
+
*/
|
|
418
|
+
async function attachOutcomeProof(response, toolName, args, sessionId, reason, outcome = "denied", paramsOverride, responseData) {
|
|
419
|
+
try {
|
|
420
|
+
const resolvedSessionId = sessionId ?? (await ensureSession());
|
|
421
|
+
if (!resolvedSessionId)
|
|
422
|
+
return response;
|
|
423
|
+
const session = await sessionManager.getSession(resolvedSessionId);
|
|
424
|
+
if (!session)
|
|
425
|
+
return response;
|
|
426
|
+
// Prefer the caller's already-stripped args so the signed requestHash
|
|
427
|
+
// matches the needs_approval / resumeToken requestHash exactly.
|
|
428
|
+
let cleanArgs;
|
|
429
|
+
if (paramsOverride !== undefined) {
|
|
430
|
+
cleanArgs = paramsOverride;
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
cleanArgs = {};
|
|
434
|
+
for (const [k, v] of Object.entries(args)) {
|
|
435
|
+
if (k !== "_kyaos_delegation")
|
|
436
|
+
cleanArgs[k] = v;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const request = { method: toolName, params: cleanArgs };
|
|
440
|
+
const proofResponse = responseData !== undefined ? { data: responseData } : undefined;
|
|
441
|
+
const proof = await proofGenerator.generateProof(request, proofResponse, session, {
|
|
442
|
+
outcome,
|
|
443
|
+
reason: sanitizeForMessage(reason),
|
|
444
|
+
});
|
|
445
|
+
response._meta = { ...(response._meta ?? {}), proof };
|
|
342
446
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
"See SECURITY.md (Unsafe Delegation Modes).");
|
|
447
|
+
catch (error) {
|
|
448
|
+
logger.error("[kya-os] Outcome proof generation failed", {
|
|
449
|
+
tool: toolName,
|
|
450
|
+
error: error instanceof Error ? error.message : String(error),
|
|
451
|
+
});
|
|
349
452
|
}
|
|
453
|
+
return response;
|
|
454
|
+
}
|
|
455
|
+
function wrapWithDelegation(toolName, config, handler) {
|
|
350
456
|
const didKeyResolver = createDidKeyResolver();
|
|
351
457
|
const fetchProvider = delegationConfig?.fetchProvider ??
|
|
352
458
|
(typeof globalThis.fetch === "function"
|
|
@@ -416,20 +522,34 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
416
522
|
content: [
|
|
417
523
|
{
|
|
418
524
|
type: "text",
|
|
419
|
-
text: JSON.stringify({ error, reason }),
|
|
525
|
+
text: JSON.stringify({ error, reason: sanitizeForMessage(reason) }),
|
|
420
526
|
},
|
|
421
527
|
],
|
|
422
528
|
isError: true,
|
|
423
529
|
});
|
|
424
530
|
const validateDelegationChain = async (leafCredential, options) => {
|
|
531
|
+
// Shape guard (validate* never-throw contract): a structurally malformed
|
|
532
|
+
// leaf returns a { valid, reason } result rather than letting
|
|
533
|
+
// extractDelegationFromVC OR the downstream scopeSatisfies check throw.
|
|
534
|
+
// Requires both credentialSubject.delegation AND .constraints (the
|
|
535
|
+
// verifier's own invariant) — the legacy-unsafe path can reach
|
|
536
|
+
// scopeSatisfies, which runs OUTSIDE the wrapper's try/catch backstop, so a
|
|
537
|
+
// constraints-less leaf must be rejected here. A hostile getter/Proxy
|
|
538
|
+
// accessor still throws → caught by the backstop, by design.
|
|
539
|
+
const leafDelegationObj = leafCredential?.credentialSubject?.delegation;
|
|
540
|
+
if (!leafDelegationObj ||
|
|
541
|
+
typeof leafDelegationObj !== "object" ||
|
|
542
|
+
!leafDelegationObj.constraints ||
|
|
543
|
+
typeof leafDelegationObj.constraints !== "object") {
|
|
544
|
+
return {
|
|
545
|
+
valid: false,
|
|
546
|
+
reason: "Malformed delegation credential: missing credentialSubject.delegation or its constraints",
|
|
547
|
+
};
|
|
548
|
+
}
|
|
425
549
|
const leafDelegation = extractDelegationFromVC(leafCredential);
|
|
426
550
|
let chain = [leafCredential];
|
|
427
551
|
if (leafDelegation.parentId) {
|
|
428
552
|
if (!delegationConfig?.resolveDelegationChain) {
|
|
429
|
-
if (legacyUnsafeDelegationEnabled) {
|
|
430
|
-
logger.warn(`[kya-os] Legacy delegation mode enabled: accepting parent-linked credential ${leafDelegation.id} without resolveDelegationChain`);
|
|
431
|
-
return { valid: true };
|
|
432
|
-
}
|
|
433
553
|
return {
|
|
434
554
|
valid: false,
|
|
435
555
|
reason: `Delegation ${leafDelegation.id} references parent ${leafDelegation.parentId} but no resolveDelegationChain handler is configured`,
|
|
@@ -475,21 +595,12 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
475
595
|
}
|
|
476
596
|
seenIds.add(delegation.id);
|
|
477
597
|
if (credential.credentialStatus && !delegationConfig?.statusListResolver) {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
return {
|
|
483
|
-
valid: false,
|
|
484
|
-
reason: `Delegation ${delegation.id} has credentialStatus but no statusListResolver is configured`,
|
|
485
|
-
};
|
|
486
|
-
}
|
|
598
|
+
return {
|
|
599
|
+
valid: false,
|
|
600
|
+
reason: `Delegation ${delegation.id} has credentialStatus but no statusListResolver is configured`,
|
|
601
|
+
};
|
|
487
602
|
}
|
|
488
|
-
const skipStatusForLegacy = legacyUnsafeDelegationEnabled &&
|
|
489
|
-
!!credential.credentialStatus &&
|
|
490
|
-
!delegationConfig?.statusListResolver;
|
|
491
603
|
const credentialVerification = await verifier.verifyDelegationCredential(credential, {
|
|
492
|
-
...(skipStatusForLegacy ? { skipStatus: true } : {}),
|
|
493
604
|
...(options?.skipSignature ? { skipSignature: true } : {}),
|
|
494
605
|
});
|
|
495
606
|
if (!credentialVerification.valid) {
|
|
@@ -504,21 +615,14 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
504
615
|
reason: `Delegation ${delegation.id} audience does not include server DID ${identity.did}`,
|
|
505
616
|
};
|
|
506
617
|
}
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
// Defaults to `true`. Set explicitly to `false` to preserve
|
|
513
|
-
// legacy behavior (logged once per process — see opt-out warning
|
|
514
|
-
// below).
|
|
515
|
-
const requireAudienceOnRedelegation = delegationConfig?.requireAudienceOnRedelegation !== false;
|
|
516
|
-
if (requireAudienceOnRedelegation &&
|
|
517
|
-
delegation.parentId &&
|
|
518
|
-
!delegation.constraints.audience) {
|
|
618
|
+
// Every non-root credential in the chain MUST carry an `audience`
|
|
619
|
+
// constraint binding it to the verifying server. This closes the
|
|
620
|
+
// confused-deputy class where a re-delegated credential is forwarded
|
|
621
|
+
// to an unintended server (KYA-OS §11.6). Unconditional as of 1.4.0.
|
|
622
|
+
if (delegation.parentId && !delegation.constraints.audience) {
|
|
519
623
|
return {
|
|
520
624
|
valid: false,
|
|
521
|
-
reason: `Delegation ${delegation.id} is a re-delegation (parentId: ${delegation.parentId}) but has no audience constraint. Re-delegations
|
|
625
|
+
reason: `Delegation ${delegation.id} is a re-delegation (parentId: ${delegation.parentId}) but has no audience constraint. Re-delegations MUST include an audience constraint (KYA-OS §11.6)`,
|
|
522
626
|
};
|
|
523
627
|
}
|
|
524
628
|
if (!previousDelegation || !previousCredential) {
|
|
@@ -583,9 +687,33 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
583
687
|
expiresAt,
|
|
584
688
|
scopes: [config.scopeId],
|
|
585
689
|
});
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
690
|
+
// Sign the challenge (outcome=needs_authorization). The proof binds a
|
|
691
|
+
// responseHash over the EMITTED challenge content — including the
|
|
692
|
+
// authorizationUrl. A verifier that recomputes the response hash over the
|
|
693
|
+
// content it received (ProofVerifier.verifyProof(proof, jwk, { request,
|
|
694
|
+
// response })) thereby detects a tampered/MITM-swapped consent URL; the
|
|
695
|
+
// signature alone proves authenticity, not content-match. config.format-
|
|
696
|
+
// Challenge lets a server render the challenge (e.g. a markdown link for
|
|
697
|
+
// LLM clients) BEFORE signing, so the proof binds exactly what the client
|
|
698
|
+
// receives. A throwing hook falls back to the default challenge (never
|
|
699
|
+
// -32603). Best-effort: attachOutcomeProof no-ops if no session resolves.
|
|
700
|
+
const defaultChallengeContent = [
|
|
701
|
+
{ type: "text", text: JSON.stringify(authError) },
|
|
702
|
+
];
|
|
703
|
+
let challengeContent = defaultChallengeContent;
|
|
704
|
+
if (config.formatChallenge) {
|
|
705
|
+
try {
|
|
706
|
+
challengeContent = config.formatChallenge(authError);
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
logger.error("[kya-os] formatChallenge threw; using the default challenge", {
|
|
710
|
+
tool: toolName,
|
|
711
|
+
error: error instanceof Error ? error.message : String(error),
|
|
712
|
+
});
|
|
713
|
+
challengeContent = defaultChallengeContent;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return attachOutcomeProof({ content: challengeContent }, toolName, args, sessionId, authError.message, "needs_authorization", undefined, challengeContent);
|
|
589
717
|
}
|
|
590
718
|
// Accept delegation as either a JSON object (embedded proof) or a
|
|
591
719
|
// VC-JWT string (compact JWT). The Cloudflare consent service issues
|
|
@@ -595,7 +723,7 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
595
723
|
if (typeof delegationArg === "string") {
|
|
596
724
|
const parsed = parseVCJWT(delegationArg);
|
|
597
725
|
if (!parsed || !parsed.payload.vc) {
|
|
598
|
-
return buildDelegationErrorResponse(KYA_OS_ERROR_CODES.delegation_invalid, "Invalid VC-JWT format");
|
|
726
|
+
return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.delegation_invalid, "Invalid VC-JWT format"), toolName, args, sessionId, "Invalid VC-JWT format");
|
|
599
727
|
}
|
|
600
728
|
vc = parsed.payload.vc;
|
|
601
729
|
// VC-JWTs don't have an embedded proof — the JWT signature is the
|
|
@@ -609,20 +737,45 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
609
737
|
else {
|
|
610
738
|
vc = delegationArg;
|
|
611
739
|
}
|
|
612
|
-
// For VC-JWTs
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
740
|
+
// For VC-JWTs the embedded-signature check is skipped (the JWT envelope
|
|
741
|
+
// signature is the proof); schema/expiry/status/scope checks still apply.
|
|
742
|
+
// validateDelegationChain performs the shape check and returns
|
|
743
|
+
// { valid, reason } for malformed input, never throwing on normal or
|
|
744
|
+
// structurally-malformed credentials. This try/catch is therefore a PURE
|
|
745
|
+
// BACKSTOP — it fires only on a truly unexpected throw (e.g. a hostile
|
|
746
|
+
// getter/Proxy accessor or a provider fault): it logs the detail
|
|
747
|
+
// server-side and returns a GENERIC reason, so no internal/implementation
|
|
748
|
+
// detail (stack, raw error text) leaks to the client or the signed proof.
|
|
749
|
+
const verificationResult = await (async () => {
|
|
750
|
+
try {
|
|
751
|
+
return await validateDelegationChain(vc, { skipSignature: isVCJWT });
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
logger.error("[kya-os] Unexpected error verifying delegation", {
|
|
755
|
+
tool: toolName,
|
|
756
|
+
error: error instanceof Error ? error.message : String(error),
|
|
757
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
758
|
+
});
|
|
759
|
+
return { valid: false, reason: "Delegation credential could not be verified" };
|
|
760
|
+
}
|
|
761
|
+
})();
|
|
618
762
|
if (!verificationResult.valid) {
|
|
619
|
-
|
|
620
|
-
|
|
763
|
+
const reason = verificationResult.reason ?? "Unknown delegation validation error";
|
|
764
|
+
logger.warn(`[kya-os] Delegation verification failed for "${toolName}": ${sanitizeForMessage(reason)}`);
|
|
765
|
+
return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.delegation_invalid, reason), toolName, args, sessionId, reason);
|
|
766
|
+
}
|
|
767
|
+
// Safe to call directly: the structural guard + validateDelegationChain
|
|
768
|
+
// above guarantee a well-formed credential here, and scopeSatisfies is
|
|
769
|
+
// bounded (ReDoS-guarded) and returns rather than throws.
|
|
770
|
+
const scopeResult = scopeSatisfies(config.scopeId, vc);
|
|
771
|
+
if (scopeResult.usedNonExactMatcher) {
|
|
772
|
+
logger.warn(`[kya-os] Scope "${config.scopeId}" for "${toolName}" granted via a non-exact ` +
|
|
773
|
+
`(prefix/regex) matcher. Verify this is intended — non-exact matchers widen authority.`);
|
|
621
774
|
}
|
|
622
|
-
|
|
623
|
-
|
|
775
|
+
if (!scopeResult.satisfied) {
|
|
776
|
+
const reason = `Required scope "${config.scopeId}" not in delegation scopes`;
|
|
624
777
|
logger.warn(`[kya-os] Delegation missing required scope "${config.scopeId}" for "${toolName}"`);
|
|
625
|
-
return buildDelegationErrorResponse(KYA_OS_ERROR_CODES.insufficient_scope,
|
|
778
|
+
return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.insufficient_scope, reason), toolName, args, sessionId, reason);
|
|
626
779
|
}
|
|
627
780
|
// Strip _kyaos_delegation from args before passing to handler
|
|
628
781
|
const cleanArgs = {};
|
|
@@ -631,7 +784,90 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
631
784
|
cleanArgs[k] = v;
|
|
632
785
|
}
|
|
633
786
|
logger.debug(`[kya-os] Delegation verified for "${toolName}", scope "${config.scopeId}"`);
|
|
634
|
-
return handler(cleanArgs, sessionId);
|
|
787
|
+
return handler(cleanArgs, sessionId, { scopeId: config.scopeId });
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
const defaultRiskClassifier = new RiskClassifier();
|
|
791
|
+
const defaultPolicyEngine = new DefaultPolicyEngine();
|
|
792
|
+
/**
|
|
793
|
+
* Per-action policy / step-up gate. Composed AFTER wrapWithDelegation (which
|
|
794
|
+
* enforces identity + scope); this wrapper adds the PROPORTIONALITY layer:
|
|
795
|
+
* classify the action's risk, ask a pluggable PolicyEngine, and either allow,
|
|
796
|
+
* deny (with a signed denial proof), or require N-of-M human approval
|
|
797
|
+
* (needs_approval) before the handler runs. It forces a decision point — it
|
|
798
|
+
* does not itself supply judgment.
|
|
799
|
+
*/
|
|
800
|
+
function withPolicyGate(toolName, handler, opts = {}) {
|
|
801
|
+
const engine = opts.engine ?? defaultPolicyEngine;
|
|
802
|
+
const classifier = opts.classifier ?? defaultRiskClassifier;
|
|
803
|
+
const approvalsKey = opts.approvalsArgKey ?? "_kyaos_approvals";
|
|
804
|
+
const isValidApprovalSignature = opts.isValidApprovalSignature ?? (async () => false);
|
|
805
|
+
return async (args, sessionId) => {
|
|
806
|
+
const controlKeys = new Set(["_kyaos_delegation", approvalsKey]);
|
|
807
|
+
const cleanArgs = {};
|
|
808
|
+
for (const [k, v] of Object.entries(args)) {
|
|
809
|
+
if (!controlKeys.has(k))
|
|
810
|
+
cleanArgs[k] = v;
|
|
811
|
+
}
|
|
812
|
+
const namespace = opts.resolveNamespace?.(args) ?? toolName;
|
|
813
|
+
const risk = classifier.classify({ toolName, namespace });
|
|
814
|
+
const principal = extractPolicyPrincipal(args["_kyaos_delegation"]);
|
|
815
|
+
const policyRequest = buildPolicyRequest({
|
|
816
|
+
principal: {
|
|
817
|
+
agentDid: principal.agentDid,
|
|
818
|
+
...(principal.responsibleParty
|
|
819
|
+
? { responsibleParty: principal.responsibleParty }
|
|
820
|
+
: {}),
|
|
821
|
+
},
|
|
822
|
+
action: { toolName },
|
|
823
|
+
resource: { namespace },
|
|
824
|
+
delegatedScopes: principal.delegatedScopes,
|
|
825
|
+
scopeMatched: opts.scopeMatched ?? false,
|
|
826
|
+
risk,
|
|
827
|
+
});
|
|
828
|
+
const decision = await engine.evaluate(policyRequest);
|
|
829
|
+
if (decision.decision === "allow") {
|
|
830
|
+
return handler(cleanArgs, sessionId);
|
|
831
|
+
}
|
|
832
|
+
if (decision.decision === "deny") {
|
|
833
|
+
const denied = {
|
|
834
|
+
content: [
|
|
835
|
+
{
|
|
836
|
+
type: "text",
|
|
837
|
+
text: JSON.stringify({
|
|
838
|
+
error: KYA_OS_ERROR_CODES.policy_denied,
|
|
839
|
+
reason: sanitizeForMessage(decision.reason),
|
|
840
|
+
}),
|
|
841
|
+
},
|
|
842
|
+
],
|
|
843
|
+
isError: true,
|
|
844
|
+
};
|
|
845
|
+
return attachOutcomeProof(denied, toolName, args, sessionId, decision.reason, "denied", cleanArgs);
|
|
846
|
+
}
|
|
847
|
+
// step_up: verify any supplied approval grants, bound to this exact action.
|
|
848
|
+
const requestHash = await proofGenerator.hashRequest({
|
|
849
|
+
method: toolName,
|
|
850
|
+
params: cleanArgs,
|
|
851
|
+
});
|
|
852
|
+
const grants = Array.isArray(args[approvalsKey])
|
|
853
|
+
? args[approvalsKey]
|
|
854
|
+
: [];
|
|
855
|
+
const quorumResult = await verifyApprovalQuorum(grants, requestHash, decision.quorum, isValidApprovalSignature);
|
|
856
|
+
if (quorumResult.satisfied) {
|
|
857
|
+
return handler(cleanArgs, sessionId);
|
|
858
|
+
}
|
|
859
|
+
const needsApproval = createNeedsApprovalError({
|
|
860
|
+
message: `Tool "${toolName}" requires ${decision.quorum.n}-of-N approval before it may proceed (${sanitizeForMessage(decision.reason)}).`,
|
|
861
|
+
resumeToken: `step_up:${requestHash}`,
|
|
862
|
+
expiresAt: Math.floor(Date.now() / 1000) + 300,
|
|
863
|
+
requestHash,
|
|
864
|
+
quorum: decision.quorum,
|
|
865
|
+
});
|
|
866
|
+
const stepUp = {
|
|
867
|
+
content: [{ type: "text", text: JSON.stringify(needsApproval) }],
|
|
868
|
+
isError: true,
|
|
869
|
+
};
|
|
870
|
+
return attachOutcomeProof(stepUp, toolName, args, sessionId, decision.reason, "step_up_required", cleanArgs);
|
|
635
871
|
};
|
|
636
872
|
}
|
|
637
873
|
return {
|
|
@@ -644,6 +880,7 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
644
880
|
handleHandshake,
|
|
645
881
|
wrapWithProof,
|
|
646
882
|
wrapWithDelegation,
|
|
883
|
+
withPolicyGate,
|
|
647
884
|
};
|
|
648
885
|
}
|
|
649
886
|
//# sourceMappingURL=with-kya-os.js.map
|