@kya-os/mcp-i 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/dist/cache/cloudflare-kv-nonce-cache.js +8 -9
- package/dist/cache/memory-nonce-cache.js +3 -1
- package/dist/cache/nonce-cache-factory.d.ts +3 -1
- package/dist/cache/nonce-cache-factory.js +25 -2
- package/dist/cli-adapter/index.js +7 -1
- package/dist/cli-adapter/kta-registration.js +3 -2
- package/dist/runtime/auth-handshake.d.ts +144 -0
- package/dist/runtime/auth-handshake.js +251 -0
- package/dist/runtime/delegation-verifier-agentshield.d.ts +64 -0
- package/dist/runtime/delegation-verifier-agentshield.js +301 -0
- package/dist/runtime/delegation-verifier-kv.d.ts +51 -0
- package/dist/runtime/delegation-verifier-kv.js +261 -0
- package/dist/runtime/delegation-verifier-memory.d.ts +50 -0
- package/dist/runtime/delegation-verifier-memory.js +128 -0
- package/dist/runtime/delegation-verifier.d.ts +133 -0
- package/dist/runtime/delegation-verifier.js +107 -0
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.js +24 -1
- package/dist/runtime/mcpi-runtime.d.ts +31 -1
- package/dist/runtime/mcpi-runtime.js +43 -1
- package/dist/runtime/proof-batch-queue.d.ts +117 -0
- package/dist/runtime/proof-batch-queue.js +257 -0
- package/package.json +12 -5
|
@@ -52,16 +52,11 @@ class CloudflareKVNonceCache {
|
|
|
52
52
|
const existingWithMetadata = await this.kv.getWithMetadata(key);
|
|
53
53
|
if (existingWithMetadata && existingWithMetadata.value !== null) {
|
|
54
54
|
// Key exists, check if it's still valid
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
throw new Error(`Nonce ${nonce} already exists - potential replay attack`);
|
|
59
|
-
}
|
|
60
|
-
// If expired, we can proceed to overwrite
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// If we can't parse existing data, assume it's corrupted and overwrite
|
|
55
|
+
const existingData = JSON.parse(existingWithMetadata.value);
|
|
56
|
+
if (Date.now() <= existingData.expiresAt) {
|
|
57
|
+
throw new Error(`Nonce ${nonce} already exists - potential replay attack`);
|
|
64
58
|
}
|
|
59
|
+
// If expired, we can proceed to overwrite
|
|
65
60
|
}
|
|
66
61
|
// Store with KV TTL as backup (convert to seconds)
|
|
67
62
|
await this.kv.put(key, JSON.stringify(data), {
|
|
@@ -69,6 +64,10 @@ class CloudflareKVNonceCache {
|
|
|
69
64
|
});
|
|
70
65
|
}
|
|
71
66
|
catch (error) {
|
|
67
|
+
// If this is a replay attack error, always rethrow it
|
|
68
|
+
if (error.message?.includes("potential replay attack")) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
72
71
|
// If getWithMetadata is not available, fall back to basic approach
|
|
73
72
|
if (error.message?.includes("getWithMetadata")) {
|
|
74
73
|
// Check if already exists first (less atomic but still functional)
|
|
@@ -15,6 +15,8 @@ class MemoryNonceCache {
|
|
|
15
15
|
cleanupInterval;
|
|
16
16
|
constructor(cleanupIntervalMs = 60000) {
|
|
17
17
|
// Default: 1 minute cleanup
|
|
18
|
+
console.warn("⚠️ MemoryNonceCache is not suitable for multi-instance deployments. " +
|
|
19
|
+
"For production use with multiple instances, configure Redis, DynamoDB, or Cloudflare KV.");
|
|
18
20
|
// Start periodic cleanup
|
|
19
21
|
this.cleanupInterval = setInterval(() => {
|
|
20
22
|
this.cleanup().catch(console.error);
|
|
@@ -43,7 +45,7 @@ class MemoryNonceCache {
|
|
|
43
45
|
// Check if nonce already exists (atomic check-and-set)
|
|
44
46
|
const existing = this.cache.get(nonce);
|
|
45
47
|
if (existing && Date.now() <= existing.expiresAt) {
|
|
46
|
-
throw new Error(`Nonce ${nonce} already exists`);
|
|
48
|
+
throw new Error(`Nonce ${nonce} already exists - potential replay attack`);
|
|
47
49
|
}
|
|
48
50
|
// Handle zero or negative TTL - set expiration to past time
|
|
49
51
|
const expiresAt = ttlSeconds <= 0 ? Date.now() - 1 : Date.now() + ttlSeconds * 1000;
|
|
@@ -6,7 +6,9 @@ export declare function detectCacheType(): "memory" | "redis" | "dynamodb" | "cl
|
|
|
6
6
|
/**
|
|
7
7
|
* Create a nonce cache instance based on configuration or environment detection
|
|
8
8
|
*/
|
|
9
|
-
export declare function createNonceCache(config?: NonceCacheConfig
|
|
9
|
+
export declare function createNonceCache(config?: NonceCacheConfig, options?: {
|
|
10
|
+
throwOnError?: boolean;
|
|
11
|
+
}): Promise<NonceCache>;
|
|
10
12
|
/**
|
|
11
13
|
* Configuration override options for nonce cache
|
|
12
14
|
*/
|
|
@@ -70,7 +70,7 @@ function detectCacheType() {
|
|
|
70
70
|
/**
|
|
71
71
|
* Create a nonce cache instance based on configuration or environment detection
|
|
72
72
|
*/
|
|
73
|
-
async function createNonceCache(config) {
|
|
73
|
+
async function createNonceCache(config, options) {
|
|
74
74
|
const cacheType = config?.type || detectCacheType();
|
|
75
75
|
switch (cacheType) {
|
|
76
76
|
case "redis": {
|
|
@@ -78,6 +78,10 @@ async function createNonceCache(config) {
|
|
|
78
78
|
process.env.REDIS_URL ||
|
|
79
79
|
getEnvWithFallback("MCPI_REDIS_URL", "XMCPI_REDIS_URL");
|
|
80
80
|
if (!redisUrl) {
|
|
81
|
+
const error = new Error("Redis URL not found");
|
|
82
|
+
if (options?.throwOnError) {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
81
85
|
console.warn("Redis URL not found, falling back to memory cache");
|
|
82
86
|
return new memory_nonce_cache_1.MemoryNonceCache();
|
|
83
87
|
}
|
|
@@ -97,6 +101,9 @@ async function createNonceCache(config) {
|
|
|
97
101
|
return new redis_nonce_cache_1.RedisNonceCache(redis, config?.redis?.keyPrefix);
|
|
98
102
|
}
|
|
99
103
|
catch (error) {
|
|
104
|
+
if (options?.throwOnError) {
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
100
107
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
101
108
|
console.warn("Failed to connect to Redis, falling back to memory cache:", errorMessage);
|
|
102
109
|
return new memory_nonce_cache_1.MemoryNonceCache();
|
|
@@ -106,6 +113,10 @@ async function createNonceCache(config) {
|
|
|
106
113
|
const tableName = config?.dynamodb?.tableName ||
|
|
107
114
|
getEnvWithFallback("MCPI_DYNAMODB_TABLE", "XMCPI_DYNAMODB_TABLE");
|
|
108
115
|
if (!tableName) {
|
|
116
|
+
const error = new Error("DynamoDB table name not found");
|
|
117
|
+
if (options?.throwOnError) {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
109
120
|
console.warn("DynamoDB table name not found, falling back to memory cache");
|
|
110
121
|
return new memory_nonce_cache_1.MemoryNonceCache();
|
|
111
122
|
}
|
|
@@ -139,6 +150,9 @@ async function createNonceCache(config) {
|
|
|
139
150
|
return new dynamodb_nonce_cache_1.DynamoNonceCache(dynamodb, tableName, config?.dynamodb?.keyAttribute, config?.dynamodb?.ttlAttribute);
|
|
140
151
|
}
|
|
141
152
|
catch (error) {
|
|
153
|
+
if (options?.throwOnError) {
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
142
156
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
143
157
|
console.warn("Failed to initialize DynamoDB, falling back to memory cache:", errorMessage);
|
|
144
158
|
return new memory_nonce_cache_1.MemoryNonceCache();
|
|
@@ -148,6 +162,10 @@ async function createNonceCache(config) {
|
|
|
148
162
|
const namespace = config?.cloudflareKv?.namespace ||
|
|
149
163
|
getEnvWithFallback("MCPI_KV_NAMESPACE", "XMCPI_KV_NAMESPACE");
|
|
150
164
|
if (!namespace) {
|
|
165
|
+
const error = new Error("Cloudflare KV namespace not found");
|
|
166
|
+
if (options?.throwOnError) {
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
151
169
|
console.warn("Cloudflare KV namespace not found, falling back to memory cache");
|
|
152
170
|
return new memory_nonce_cache_1.MemoryNonceCache();
|
|
153
171
|
}
|
|
@@ -181,6 +199,9 @@ async function createNonceCache(config) {
|
|
|
181
199
|
return new cloudflare_kv_nonce_cache_1.CloudflareKVNonceCache(kv, config?.cloudflareKv?.keyPrefix);
|
|
182
200
|
}
|
|
183
201
|
catch (error) {
|
|
202
|
+
if (options?.throwOnError) {
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
184
205
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
185
206
|
console.warn("Failed to initialize Cloudflare KV, falling back to memory cache:", errorMessage);
|
|
186
207
|
return new memory_nonce_cache_1.MemoryNonceCache();
|
|
@@ -196,7 +217,9 @@ async function createNonceCache(config) {
|
|
|
196
217
|
*/
|
|
197
218
|
async function createNonceCacheWithConfig(options = {}) {
|
|
198
219
|
try {
|
|
199
|
-
return await createNonceCache(options.config
|
|
220
|
+
return await createNonceCache(options.config, {
|
|
221
|
+
throwOnError: options.fallbackToMemory === false,
|
|
222
|
+
});
|
|
200
223
|
}
|
|
201
224
|
catch (error) {
|
|
202
225
|
if (options.fallbackToMemory !== false) {
|
|
@@ -23,7 +23,13 @@ async function enableMCPIdentityCLI(options = {}) {
|
|
|
23
23
|
// Helper to emit progress events
|
|
24
24
|
const emitProgress = async (stage, message, data) => {
|
|
25
25
|
if (onProgress) {
|
|
26
|
-
|
|
26
|
+
try {
|
|
27
|
+
await onProgress({ stage, message, data });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
// Continue even if progress callback throws
|
|
31
|
+
console.warn("Warning: Progress callback threw an error:", error);
|
|
32
|
+
}
|
|
27
33
|
}
|
|
28
34
|
};
|
|
29
35
|
// Step 1: Check for existing identity
|
|
@@ -57,11 +57,12 @@ async function registerWithKTA(options) {
|
|
|
57
57
|
// Extract agent ID from DID
|
|
58
58
|
const agentId = did.split(":").pop() || `agent-${Date.now()}`;
|
|
59
59
|
// Build result structure
|
|
60
|
+
// Handle both camelCase (agentUrl, claimUrl) and uppercase (agentURL, claimURL) from API
|
|
60
61
|
const result = {
|
|
61
62
|
agentDID: did,
|
|
62
|
-
agentURL: data.agentURL || `${endpoint}/agents/by-did/${encodeURIComponent(did)}`,
|
|
63
|
+
agentURL: data.agentURL || data.agentUrl || `${endpoint}/agents/by-did/${encodeURIComponent(did)}`,
|
|
63
64
|
agentId: data.agentId || agentId,
|
|
64
|
-
claimURL: data.claimURL || `${endpoint}/claim/${agentId}`,
|
|
65
|
+
claimURL: data.claimURL || data.claimUrl || `${endpoint}/claim/${agentId}`,
|
|
65
66
|
verificationEndpoint,
|
|
66
67
|
conformanceCapabilities: MCP_I_CAPABILITIES,
|
|
67
68
|
mirrorStatus: data.mirrorStatus || "pending",
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization Handshake Module
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the authorization flow for MCP-I bouncer:
|
|
5
|
+
* 1. Check agent reputation (optional)
|
|
6
|
+
* 2. Verify delegation exists
|
|
7
|
+
* 3. Return needs_authorization error if missing
|
|
8
|
+
*
|
|
9
|
+
* This module implements the "gatekeeper" logic that determines whether
|
|
10
|
+
* an agent should be allowed to execute a tool or needs human authorization.
|
|
11
|
+
*
|
|
12
|
+
* Flow:
|
|
13
|
+
* - If delegation exists + valid → allow (fast path)
|
|
14
|
+
* - If delegation missing → return needs_authorization with hints
|
|
15
|
+
* - If reputation too low (optional) → require authorization
|
|
16
|
+
*
|
|
17
|
+
* Related: PHASE_1_XMCP_I_SERVER.md Epic 2 (Runtime Interceptor)
|
|
18
|
+
*/
|
|
19
|
+
import { NeedsAuthorizationError } from '@kya-os/contracts/runtime';
|
|
20
|
+
import { DelegationVerifier } from './delegation-verifier';
|
|
21
|
+
import { DelegationRecord } from '@kya-os/contracts/delegation';
|
|
22
|
+
/**
|
|
23
|
+
* Agent reputation data from KTA
|
|
24
|
+
*/
|
|
25
|
+
export interface AgentReputation {
|
|
26
|
+
/** Agent DID */
|
|
27
|
+
agentDid: string;
|
|
28
|
+
/** Reputation score (0-100) */
|
|
29
|
+
score: number;
|
|
30
|
+
/** Total interactions recorded */
|
|
31
|
+
totalInteractions: number;
|
|
32
|
+
/** Success rate (0-1) */
|
|
33
|
+
successRate: number;
|
|
34
|
+
/** Risk level assessment */
|
|
35
|
+
riskLevel: 'low' | 'medium' | 'high' | 'unknown';
|
|
36
|
+
/** Last updated timestamp */
|
|
37
|
+
updatedAt: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Configuration for auth handshake
|
|
41
|
+
*/
|
|
42
|
+
export interface AuthHandshakeConfig {
|
|
43
|
+
/** Delegation verifier instance */
|
|
44
|
+
delegationVerifier: DelegationVerifier;
|
|
45
|
+
/** KTA API configuration (optional, for reputation checks) */
|
|
46
|
+
kta?: {
|
|
47
|
+
apiUrl: string;
|
|
48
|
+
apiKey?: string;
|
|
49
|
+
};
|
|
50
|
+
/** Bouncer configuration */
|
|
51
|
+
bouncer: {
|
|
52
|
+
/** Authorization URL base (e.g., "https://agentshield.example.com/consent") */
|
|
53
|
+
authorizationUrl: string;
|
|
54
|
+
/** Resume token TTL in milliseconds (default: 10 minutes) */
|
|
55
|
+
resumeTokenTtl?: number;
|
|
56
|
+
/** Whether to require authorization for unknown/untrusted agents */
|
|
57
|
+
requireAuthForUnknown?: boolean;
|
|
58
|
+
/** Minimum reputation score to bypass authorization (0-100) */
|
|
59
|
+
minReputationScore?: number;
|
|
60
|
+
};
|
|
61
|
+
/** Enable debug logging */
|
|
62
|
+
debug?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Result of auth handshake verification
|
|
66
|
+
*/
|
|
67
|
+
export interface VerifyOrHintsResult {
|
|
68
|
+
/** Whether authorization is granted */
|
|
69
|
+
authorized: boolean;
|
|
70
|
+
/** Delegation record (if authorized) */
|
|
71
|
+
delegation?: DelegationRecord;
|
|
72
|
+
/** needs_authorization error (if not authorized) */
|
|
73
|
+
authError?: NeedsAuthorizationError;
|
|
74
|
+
/** Agent reputation data (if available) */
|
|
75
|
+
reputation?: AgentReputation;
|
|
76
|
+
/** Reason for decision */
|
|
77
|
+
reason?: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resume token store interface
|
|
81
|
+
*
|
|
82
|
+
* Stores short-lived tokens for resuming after authorization
|
|
83
|
+
*/
|
|
84
|
+
export interface ResumeTokenStore {
|
|
85
|
+
/**
|
|
86
|
+
* Create resume token
|
|
87
|
+
*
|
|
88
|
+
* @param agentDid - Agent DID
|
|
89
|
+
* @param scopes - Required scopes
|
|
90
|
+
* @param metadata - Optional metadata (user agent, IP, etc.)
|
|
91
|
+
* @returns Resume token string
|
|
92
|
+
*/
|
|
93
|
+
create(agentDid: string, scopes: string[], metadata?: Record<string, any>): Promise<string>;
|
|
94
|
+
/**
|
|
95
|
+
* Get resume token data
|
|
96
|
+
*
|
|
97
|
+
* @param token - Resume token
|
|
98
|
+
* @returns Token data or null if expired/invalid
|
|
99
|
+
*/
|
|
100
|
+
get(token: string): Promise<{
|
|
101
|
+
agentDid: string;
|
|
102
|
+
scopes: string[];
|
|
103
|
+
createdAt: number;
|
|
104
|
+
expiresAt: number;
|
|
105
|
+
metadata?: Record<string, any>;
|
|
106
|
+
} | null>;
|
|
107
|
+
/**
|
|
108
|
+
* Mark token as fulfilled (one-time use)
|
|
109
|
+
*
|
|
110
|
+
* @param token - Resume token
|
|
111
|
+
*/
|
|
112
|
+
fulfill(token: string): Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Simple in-memory resume token store (for testing/development)
|
|
116
|
+
*/
|
|
117
|
+
export declare class MemoryResumeTokenStore implements ResumeTokenStore {
|
|
118
|
+
private tokens;
|
|
119
|
+
private ttl;
|
|
120
|
+
constructor(ttlMs?: number);
|
|
121
|
+
create(agentDid: string, scopes: string[], metadata?: Record<string, any>): Promise<string>;
|
|
122
|
+
get(token: string): Promise<any | null>;
|
|
123
|
+
fulfill(token: string): Promise<void>;
|
|
124
|
+
clear(): void;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Main auth handshake function
|
|
128
|
+
*
|
|
129
|
+
* Verifies agent authorization or returns authorization hints
|
|
130
|
+
*
|
|
131
|
+
* @param agentDid - Agent DID requesting access
|
|
132
|
+
* @param scopes - Required permission scopes
|
|
133
|
+
* @param config - Auth handshake configuration
|
|
134
|
+
* @param resumeToken - Optional resume token from previous authorization
|
|
135
|
+
* @returns Verification result with delegation or auth error
|
|
136
|
+
*/
|
|
137
|
+
export declare function verifyOrHints(agentDid: string, scopes: string[], config: AuthHandshakeConfig, resumeToken?: string): Promise<VerifyOrHintsResult>;
|
|
138
|
+
/**
|
|
139
|
+
* Helper: Check if scopes are sensitive and require authorization
|
|
140
|
+
*
|
|
141
|
+
* @param scopes - Scopes to check
|
|
142
|
+
* @returns true if scopes are sensitive
|
|
143
|
+
*/
|
|
144
|
+
export declare function hasSensitiveScopes(scopes: string[]): boolean;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Authorization Handshake Module
|
|
4
|
+
*
|
|
5
|
+
* Orchestrates the authorization flow for MCP-I bouncer:
|
|
6
|
+
* 1. Check agent reputation (optional)
|
|
7
|
+
* 2. Verify delegation exists
|
|
8
|
+
* 3. Return needs_authorization error if missing
|
|
9
|
+
*
|
|
10
|
+
* This module implements the "gatekeeper" logic that determines whether
|
|
11
|
+
* an agent should be allowed to execute a tool or needs human authorization.
|
|
12
|
+
*
|
|
13
|
+
* Flow:
|
|
14
|
+
* - If delegation exists + valid → allow (fast path)
|
|
15
|
+
* - If delegation missing → return needs_authorization with hints
|
|
16
|
+
* - If reputation too low (optional) → require authorization
|
|
17
|
+
*
|
|
18
|
+
* Related: PHASE_1_XMCP_I_SERVER.md Epic 2 (Runtime Interceptor)
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.MemoryResumeTokenStore = void 0;
|
|
22
|
+
exports.verifyOrHints = verifyOrHints;
|
|
23
|
+
exports.hasSensitiveScopes = hasSensitiveScopes;
|
|
24
|
+
const runtime_1 = require("@kya-os/contracts/runtime");
|
|
25
|
+
/**
|
|
26
|
+
* Simple in-memory resume token store (for testing/development)
|
|
27
|
+
*/
|
|
28
|
+
class MemoryResumeTokenStore {
|
|
29
|
+
tokens = new Map();
|
|
30
|
+
ttl;
|
|
31
|
+
constructor(ttlMs = 600_000) {
|
|
32
|
+
this.ttl = ttlMs;
|
|
33
|
+
}
|
|
34
|
+
async create(agentDid, scopes, metadata) {
|
|
35
|
+
const token = `rt_${Date.now()}_${Math.random().toString(36).substr(2, 16)}`;
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
this.tokens.set(token, {
|
|
38
|
+
agentDid,
|
|
39
|
+
scopes,
|
|
40
|
+
createdAt: now,
|
|
41
|
+
expiresAt: now + this.ttl,
|
|
42
|
+
metadata,
|
|
43
|
+
fulfilled: false,
|
|
44
|
+
});
|
|
45
|
+
return token;
|
|
46
|
+
}
|
|
47
|
+
async get(token) {
|
|
48
|
+
const data = this.tokens.get(token);
|
|
49
|
+
if (!data)
|
|
50
|
+
return null;
|
|
51
|
+
// Check expiration
|
|
52
|
+
if (Date.now() > data.expiresAt) {
|
|
53
|
+
this.tokens.delete(token);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
// Check if already fulfilled
|
|
57
|
+
if (data.fulfilled) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
async fulfill(token) {
|
|
63
|
+
const data = this.tokens.get(token);
|
|
64
|
+
if (data) {
|
|
65
|
+
data.fulfilled = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
clear() {
|
|
69
|
+
this.tokens.clear();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
exports.MemoryResumeTokenStore = MemoryResumeTokenStore;
|
|
73
|
+
/**
|
|
74
|
+
* Main auth handshake function
|
|
75
|
+
*
|
|
76
|
+
* Verifies agent authorization or returns authorization hints
|
|
77
|
+
*
|
|
78
|
+
* @param agentDid - Agent DID requesting access
|
|
79
|
+
* @param scopes - Required permission scopes
|
|
80
|
+
* @param config - Auth handshake configuration
|
|
81
|
+
* @param resumeToken - Optional resume token from previous authorization
|
|
82
|
+
* @returns Verification result with delegation or auth error
|
|
83
|
+
*/
|
|
84
|
+
async function verifyOrHints(agentDid, scopes, config, resumeToken) {
|
|
85
|
+
const startTime = Date.now();
|
|
86
|
+
if (config.debug) {
|
|
87
|
+
console.log(`[AuthHandshake] Verifying ${agentDid} for scopes: ${scopes.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
// Step 1: Check reputation (optional, if KTA configured)
|
|
90
|
+
let reputation;
|
|
91
|
+
if (config.kta && config.bouncer.minReputationScore !== undefined) {
|
|
92
|
+
try {
|
|
93
|
+
reputation = await fetchAgentReputation(agentDid, config.kta);
|
|
94
|
+
if (config.debug) {
|
|
95
|
+
console.log(`[AuthHandshake] Reputation score: ${reputation.score}`);
|
|
96
|
+
}
|
|
97
|
+
// If reputation is too low, require authorization
|
|
98
|
+
if (reputation.score < config.bouncer.minReputationScore) {
|
|
99
|
+
if (config.debug) {
|
|
100
|
+
console.log(`[AuthHandshake] Reputation ${reputation.score} < ${config.bouncer.minReputationScore}, requiring authorization`);
|
|
101
|
+
}
|
|
102
|
+
const authError = await buildNeedsAuthorizationError(agentDid, scopes, config, 'Agent reputation score below threshold');
|
|
103
|
+
return {
|
|
104
|
+
authorized: false,
|
|
105
|
+
authError,
|
|
106
|
+
reputation,
|
|
107
|
+
reason: 'Low reputation score',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// Don't fail hard on reputation check failure
|
|
113
|
+
console.warn('[AuthHandshake] Failed to check reputation:', error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Step 2: Check for existing delegation
|
|
117
|
+
let delegationResult;
|
|
118
|
+
try {
|
|
119
|
+
delegationResult = await config.delegationVerifier.verify(agentDid, scopes);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
console.error('[AuthHandshake] Delegation verification failed:', error);
|
|
123
|
+
return {
|
|
124
|
+
authorized: false,
|
|
125
|
+
reason: `Delegation verification error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// Step 3: If delegation exists and valid, authorize immediately
|
|
129
|
+
if (delegationResult.valid && delegationResult.delegation) {
|
|
130
|
+
if (config.debug) {
|
|
131
|
+
console.log(`[AuthHandshake] Delegation valid, authorized (${Date.now() - startTime}ms)`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
authorized: true,
|
|
135
|
+
delegation: delegationResult.delegation,
|
|
136
|
+
reputation,
|
|
137
|
+
reason: 'Valid delegation found',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Step 4: No delegation found - return needs_authorization error
|
|
141
|
+
if (config.debug) {
|
|
142
|
+
console.log(`[AuthHandshake] No delegation found, returning needs_authorization (${Date.now() - startTime}ms)`);
|
|
143
|
+
}
|
|
144
|
+
const authError = await buildNeedsAuthorizationError(agentDid, scopes, config, delegationResult.reason || 'No valid delegation found');
|
|
145
|
+
return {
|
|
146
|
+
authorized: false,
|
|
147
|
+
authError,
|
|
148
|
+
reputation,
|
|
149
|
+
reason: delegationResult.reason || 'No delegation',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Fetch agent reputation from KTA
|
|
154
|
+
*
|
|
155
|
+
* @param agentDid - Agent DID
|
|
156
|
+
* @param ktaConfig - KTA API configuration
|
|
157
|
+
* @returns Agent reputation data
|
|
158
|
+
*/
|
|
159
|
+
async function fetchAgentReputation(agentDid, ktaConfig) {
|
|
160
|
+
const apiUrl = ktaConfig.apiUrl.replace(/\/$/, '');
|
|
161
|
+
const headers = {
|
|
162
|
+
'Content-Type': 'application/json',
|
|
163
|
+
};
|
|
164
|
+
if (ktaConfig.apiKey) {
|
|
165
|
+
headers['X-API-Key'] = ktaConfig.apiKey;
|
|
166
|
+
}
|
|
167
|
+
const response = await fetch(`${apiUrl}/api/v1/reputation/${encodeURIComponent(agentDid)}`, {
|
|
168
|
+
method: 'GET',
|
|
169
|
+
headers,
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
if (response.status === 404) {
|
|
173
|
+
// Agent not registered, return default "unknown" reputation
|
|
174
|
+
return {
|
|
175
|
+
agentDid,
|
|
176
|
+
score: 50, // Neutral score
|
|
177
|
+
totalInteractions: 0,
|
|
178
|
+
successRate: 0,
|
|
179
|
+
riskLevel: 'unknown',
|
|
180
|
+
updatedAt: Date.now(),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
throw new Error(`KTA API error: ${response.status} ${response.statusText}`);
|
|
184
|
+
}
|
|
185
|
+
const data = await response.json();
|
|
186
|
+
return {
|
|
187
|
+
agentDid: data.agentDid || agentDid,
|
|
188
|
+
score: data.score || 50,
|
|
189
|
+
totalInteractions: data.totalInteractions || 0,
|
|
190
|
+
successRate: data.successRate || 0,
|
|
191
|
+
riskLevel: data.riskLevel || 'unknown',
|
|
192
|
+
updatedAt: data.updatedAt || Date.now(),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Build needs_authorization error with hints
|
|
197
|
+
*
|
|
198
|
+
* @param agentDid - Agent DID
|
|
199
|
+
* @param scopes - Required scopes
|
|
200
|
+
* @param config - Auth handshake configuration
|
|
201
|
+
* @param message - Human-readable error message
|
|
202
|
+
* @returns NeedsAuthorizationError
|
|
203
|
+
*/
|
|
204
|
+
async function buildNeedsAuthorizationError(agentDid, scopes, config, message) {
|
|
205
|
+
// Generate resume token (simple implementation - use proper store in production)
|
|
206
|
+
const resumeTokenStore = new MemoryResumeTokenStore(config.bouncer.resumeTokenTtl || 600_000);
|
|
207
|
+
const resumeToken = await resumeTokenStore.create(agentDid, scopes, {
|
|
208
|
+
requestedAt: Date.now(),
|
|
209
|
+
});
|
|
210
|
+
const expiresAt = Date.now() + (config.bouncer.resumeTokenTtl || 600_000);
|
|
211
|
+
// Build authorization URL
|
|
212
|
+
const authUrl = new URL(config.bouncer.authorizationUrl);
|
|
213
|
+
authUrl.searchParams.set('agent_did', agentDid);
|
|
214
|
+
authUrl.searchParams.set('scopes', scopes.join(','));
|
|
215
|
+
authUrl.searchParams.set('resume_token', resumeToken);
|
|
216
|
+
// Generate short authorization code (for display)
|
|
217
|
+
const authCode = resumeToken.substring(0, 8).toUpperCase();
|
|
218
|
+
// Build display hints
|
|
219
|
+
const display = {
|
|
220
|
+
title: 'Authorization Required',
|
|
221
|
+
hint: ['link', 'qr'],
|
|
222
|
+
authorizationCode: authCode,
|
|
223
|
+
qrUrl: `https://chart.googleapis.com/chart?cht=qr&chs=300x300&chl=${encodeURIComponent(authUrl.toString())}`,
|
|
224
|
+
};
|
|
225
|
+
return (0, runtime_1.createNeedsAuthorizationError)({
|
|
226
|
+
message,
|
|
227
|
+
authorizationUrl: authUrl.toString(),
|
|
228
|
+
resumeToken,
|
|
229
|
+
expiresAt,
|
|
230
|
+
scopes,
|
|
231
|
+
display,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Helper: Check if scopes are sensitive and require authorization
|
|
236
|
+
*
|
|
237
|
+
* @param scopes - Scopes to check
|
|
238
|
+
* @returns true if scopes are sensitive
|
|
239
|
+
*/
|
|
240
|
+
function hasSensitiveScopes(scopes) {
|
|
241
|
+
const sensitivePatterns = [
|
|
242
|
+
'write',
|
|
243
|
+
'delete',
|
|
244
|
+
'admin',
|
|
245
|
+
'payment',
|
|
246
|
+
'transfer',
|
|
247
|
+
'execute',
|
|
248
|
+
'modify',
|
|
249
|
+
];
|
|
250
|
+
return scopes.some((scope) => sensitivePatterns.some((pattern) => scope.toLowerCase().includes(pattern)));
|
|
251
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentShield API Delegation Verifier
|
|
3
|
+
*
|
|
4
|
+
* Queries delegations from AgentShield managed service API.
|
|
5
|
+
* Includes local caching to minimize network calls and maximize performance.
|
|
6
|
+
*
|
|
7
|
+
* Performance:
|
|
8
|
+
* - Fast path (cached): < 5ms
|
|
9
|
+
* - Slow path (API call): < 100ms
|
|
10
|
+
* - Cache TTL: 1 minute (configurable)
|
|
11
|
+
*
|
|
12
|
+
* API Endpoints:
|
|
13
|
+
* - POST /api/v1/delegations/verify - Verify delegation by agent DID + scopes
|
|
14
|
+
* - GET /api/v1/delegations/:id - Get delegation by ID
|
|
15
|
+
* - POST /api/v1/delegations - Create new delegation (admin only)
|
|
16
|
+
* - POST /api/v1/delegations/:id/revoke - Revoke delegation (admin only)
|
|
17
|
+
*
|
|
18
|
+
* Authentication: Bearer token via X-API-Key header
|
|
19
|
+
*
|
|
20
|
+
* Related: PHASE_1_XMCP_I_SERVER.md Ticket 1.3
|
|
21
|
+
* Related: AGENTSHIELD_DASHBOARD_PLAN.md Epic 2 (Public API)
|
|
22
|
+
*/
|
|
23
|
+
import { DelegationRecord } from '@kya-os/contracts/delegation';
|
|
24
|
+
import { DelegationVerifier, DelegationVerifierConfig, VerifyDelegationResult, VerifyDelegationOptions } from './delegation-verifier';
|
|
25
|
+
/**
|
|
26
|
+
* AgentShield API Delegation Verifier
|
|
27
|
+
*
|
|
28
|
+
* Managed mode: Queries delegations from AgentShield dashboard API
|
|
29
|
+
*/
|
|
30
|
+
export declare class AgentShieldAPIDelegationVerifier implements DelegationVerifier {
|
|
31
|
+
private apiUrl;
|
|
32
|
+
private apiKey;
|
|
33
|
+
private cache;
|
|
34
|
+
private cacheTtl;
|
|
35
|
+
private debug;
|
|
36
|
+
constructor(config: DelegationVerifierConfig);
|
|
37
|
+
/**
|
|
38
|
+
* Verify agent delegation via API
|
|
39
|
+
*/
|
|
40
|
+
verify(agentDid: string, scopes: string[], options?: VerifyDelegationOptions): Promise<VerifyDelegationResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Get delegation by ID via API
|
|
43
|
+
*/
|
|
44
|
+
get(delegationId: string): Promise<DelegationRecord | null>;
|
|
45
|
+
/**
|
|
46
|
+
* Store delegation (admin operation via API)
|
|
47
|
+
*
|
|
48
|
+
* Note: This is typically done via AgentShield dashboard UI,
|
|
49
|
+
* not by the bouncer directly. Included for completeness.
|
|
50
|
+
*/
|
|
51
|
+
put(delegation: DelegationRecord): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Revoke delegation via API
|
|
54
|
+
*/
|
|
55
|
+
revoke(delegationId: string, reason?: string): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Close connections (cleanup)
|
|
58
|
+
*/
|
|
59
|
+
close(): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Build deterministic scopes key for caching
|
|
62
|
+
*/
|
|
63
|
+
private buildScopesKey;
|
|
64
|
+
}
|