@kya-os/mcp-i-core 1.3.23 → 1.3.25

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.
@@ -5,11 +5,23 @@
5
5
  * This service manages tool protection configuration from AgentShield API with
6
6
  * efficient caching and automatic synchronization support.
7
7
  *
8
+ * CONFIGURATION PRECEDENCE (highest to lowest):
9
+ * ---------------------------------------------
10
+ * 1. Remote config (AgentShield API) - WINS (source of truth)
11
+ * 2. Local config (localConfig) - Base defaults, overridden by remote
12
+ * 3. Framework defaults
13
+ *
14
+ * OFFLINE FALLBACK (only when API unavailable):
15
+ * ---------------------------------------------
16
+ * 1. Stale cache (if allowStaleCache=true and within maxStaleCacheAge)
17
+ * 2. offlineFallbackConfig (explicit offline-only config)
18
+ * 3. failSafeBehavior (deny-all or allow-all)
19
+ *
8
20
  * CORE FUNCTIONALITY:
9
21
  * -------------------
10
- * 1. Fetches tool protection config from AgentShield API
11
- * 2. Caches responses with configurable TTL (default 5 minutes)
12
- * 3. Falls back to local config if API unavailable
22
+ * 1. Fetches tool protection config from AgentShield API (remote wins)
23
+ * 2. Merges with local config (local serves as base defaults)
24
+ * 3. Caches responses with configurable TTL (default 5 minutes)
13
25
  * 4. Provides delegation requirement checking before tool execution
14
26
  *
15
27
  * SYNCHRONIZATION WITH AGENTSHIELD:
@@ -131,7 +143,7 @@ class ToolProtectionService {
131
143
  // Check if expired but within maxStaleCacheAge
132
144
  if (now > expiresAt && now - expiresAt <= maxStaleAge) {
133
145
  if (this.config.debug) {
134
- console.log("[ToolProtectionService] Using stale cache", {
146
+ console.error("[ToolProtectionService] Using stale cache", {
135
147
  cacheKey,
136
148
  expiredAt: new Date(expiresAt).toISOString(),
137
149
  staleAgeMs: now - expiresAt,
@@ -166,7 +178,7 @@ class ToolProtectionService {
166
178
  const ttl = this.config.cacheTtl ?? 300000;
167
179
  const cachedUntil = new Date(Date.now() + ttl).toISOString();
168
180
  if (this.config.debug) {
169
- console.log("[ToolProtectionService] Cache hit", {
181
+ console.error("[ToolProtectionService] Cache hit", {
170
182
  source: "cache",
171
183
  cacheKey,
172
184
  agentDid: agentDid.slice(0, 20) + "...",
@@ -182,7 +194,7 @@ class ToolProtectionService {
182
194
  return cached;
183
195
  }
184
196
  if (this.config.debug) {
185
- console.log("[ToolProtectionService] Cache miss, fetching from API", {
197
+ console.error("[ToolProtectionService] Cache miss, fetching from API", {
186
198
  source: "api-fetch-start",
187
199
  cacheKey,
188
200
  agentDid: agentDid.slice(0, 20) + "...",
@@ -197,7 +209,7 @@ class ToolProtectionService {
197
209
  try {
198
210
  const response = await this.fetchFromApi(agentDid);
199
211
  if (this.config.debug) {
200
- console.log("[ToolProtectionService] API response received", {
212
+ console.error("[ToolProtectionService] API response received", {
201
213
  source: "api-fetch-complete",
202
214
  agentDid: agentDid.slice(0, 20) + "...",
203
215
  projectId: this.config.projectId || "none",
@@ -205,166 +217,70 @@ class ToolProtectionService {
205
217
  dataKeys: response.data ? Object.keys(response.data) : [],
206
218
  rawConfig: response.data?.config || null,
207
219
  rawConfigToolProtection: response.data?.config?.toolProtection || null,
208
- rawToolProtections: response.data?.toolProtections || null,
209
- rawTools: response.data?.tools || null,
210
220
  responseMetadata: response.metadata || null,
211
221
  });
212
222
  }
213
- // Transform API response format to internal format
214
- // Supports multiple response formats (in priority order):
215
- // 1. Merged config endpoint: { data: { config: { toolProtection: { tools: {...} } } } }
216
- // 2. Legacy toolProtections: { data: { toolProtections: { greet: { requiresDelegation: true, ... } } } }
217
- // 3. Old endpoint (array): { data: { tools: [{ name: "greet", requiresDelegation: true, ... }] } }
218
- // 4. Old endpoint (object): { data: { tools: { greet: { requiresDelegation: true, ... } } } }
219
- const toolProtections = {};
220
- // Check for merged config format first (data.config.toolProtection.tools)
221
- if (response.data.config?.toolProtection?.tools) {
222
- // Merged config endpoint format: object with tool names as keys
223
- if (this.config.debug) {
224
- console.log("[ToolProtectionService] Using merged config format (data.config.toolProtection.tools)");
225
- }
226
- for (const [toolName, toolConfig] of Object.entries(response.data.config.toolProtection.tools)) {
227
- const requiresDelegation = toolConfig.requiresDelegation ??
228
- toolConfig.requires_delegation ??
229
- false;
230
- const requiredScopes = toolConfig.requiredScopes ??
231
- toolConfig.required_scopes ??
232
- toolConfig.scopes ??
233
- [];
234
- const oauthProvider = toolConfig.oauthProvider ??
235
- toolConfig.oauth_provider ??
236
- undefined;
237
- const riskLevel = toolConfig.riskLevel ??
238
- toolConfig.risk_level ??
239
- undefined;
240
- toolProtections[toolName] = {
241
- requiresDelegation,
242
- requiredScopes,
243
- ...(oauthProvider && { oauthProvider }),
244
- ...(riskLevel && { riskLevel }),
245
- };
246
- }
247
- }
248
- else if (response.data.toolProtections) {
249
- // Legacy toolProtections format: object with tool names as keys
250
- if (this.config.debug) {
251
- console.log("[ToolProtectionService] Using legacy toolProtections format (data.toolProtections)");
252
- }
253
- // Prefer camelCase over snake_case when both present
254
- for (const [toolName, toolConfig] of Object.entries(response.data.toolProtections)) {
255
- const requiresDelegation = toolConfig.requiresDelegation ??
256
- toolConfig.requires_delegation ??
257
- false;
258
- const requiredScopes = toolConfig.requiredScopes ??
259
- toolConfig.required_scopes ??
260
- toolConfig.scopes ??
261
- [];
262
- // NEW: Parse oauthProvider (camelCase and snake_case support)
263
- const oauthProvider = toolConfig.oauthProvider ??
264
- toolConfig.oauth_provider ??
265
- undefined;
266
- const riskLevel = toolConfig.riskLevel ??
267
- toolConfig.risk_level ??
268
- undefined;
269
- toolProtections[toolName] = {
270
- requiresDelegation,
271
- requiredScopes,
272
- ...(oauthProvider && { oauthProvider }), // Only include if present
273
- ...(riskLevel && { riskLevel }), // Only include if present
274
- };
275
- }
276
- }
277
- else if (response.data.tools) {
278
- // Old endpoint format: array or object
279
- if (Array.isArray(response.data.tools)) {
280
- // Array format: [{ name: "greet", requiresDelegation: true, ... }]
281
- for (const tool of response.data.tools) {
282
- const toolName = tool.name;
283
- if (!toolName) {
284
- if (this.config.debug) {
285
- console.warn("[ToolProtectionService] Tool missing name in array format", tool);
286
- }
287
- continue;
288
- }
289
- // Prefer camelCase over snake_case when both present
290
- const requiresDelegation = tool.requiresDelegation ??
291
- tool.requires_delegation ??
292
- false;
293
- const requiredScopes = tool.requiredScopes ??
294
- tool.required_scopes ??
295
- tool.scopes ??
296
- [];
297
- // NEW: Parse oauthProvider
298
- const oauthProvider = tool.oauthProvider ??
299
- tool.oauth_provider ??
300
- undefined;
301
- const riskLevel = tool.riskLevel ?? tool.risk_level ?? undefined;
302
- toolProtections[toolName] = {
303
- requiresDelegation,
304
- requiredScopes,
305
- ...(oauthProvider && { oauthProvider }),
306
- ...(riskLevel && { riskLevel }),
307
- };
308
- }
309
- }
310
- else {
311
- // Object format: { greet: { requiresDelegation: true, ... } }
312
- for (const [toolName, toolConfig] of Object.entries(response.data.tools)) {
313
- // Prefer camelCase over snake_case when both present
314
- const requiresDelegation = toolConfig.requiresDelegation ??
315
- toolConfig.requires_delegation ??
316
- false;
317
- const requiredScopes = toolConfig.requiredScopes ??
318
- toolConfig.required_scopes ??
319
- toolConfig.scopes ??
320
- [];
321
- // NEW: Parse oauthProvider
322
- const oauthProvider = toolConfig.oauthProvider ??
323
- toolConfig.oauth_provider ??
324
- undefined;
325
- const riskLevel = toolConfig.riskLevel ??
326
- toolConfig.risk_level ??
327
- undefined;
328
- toolProtections[toolName] = {
329
- requiresDelegation,
330
- requiredScopes,
331
- ...(oauthProvider && { oauthProvider }),
332
- ...(riskLevel && { riskLevel }),
333
- };
334
- }
335
- }
223
+ // Transform API response to internal format
224
+ // Uses merged config endpoint: { data: { config: { toolProtection: { tools: {...} } } } }
225
+ const toolProtections = this.parseToolProtectionsFromResponse(response);
226
+ if (this.config.debug && Object.keys(toolProtections).length > 0) {
227
+ console.error("[ToolProtectionService] Parsed tool protections from merged config", {
228
+ toolCount: Object.keys(toolProtections).length,
229
+ tools: Object.keys(toolProtections),
230
+ });
336
231
  }
337
- // Merge with fallback config (local config takes priority over API)
338
- // This allows users to override API responses with local configuration
339
- // Note: Fallback config is also used when API fails, but here we merge even when API succeeds
340
- const mergedToolProtections = { ...toolProtections };
341
- if (this.config.fallbackConfig?.toolProtections) {
342
- for (const [toolName, localConfig] of Object.entries(this.config.fallbackConfig.toolProtections)) {
232
+ // Merge with local config (REMOTE API takes priority over local)
233
+ // Standard precedence: Default < Local < Remote
234
+ // Remote (AgentShield dashboard) is the source of truth and WINS over local config
235
+ const mergedToolProtections = {};
236
+ // Step 1: Apply local config as base (if provided)
237
+ if (this.config.localConfig?.toolProtections) {
238
+ for (const [toolName, localConfig] of Object.entries(this.config.localConfig.toolProtections)) {
343
239
  // Skip if localConfig is empty or not a valid ToolProtection object
344
240
  // This prevents empty objects from corrupting the merged config
345
241
  if (!localConfig ||
346
242
  typeof localConfig !== "object" ||
347
243
  Object.keys(localConfig).length === 0) {
348
244
  if (this.config.debug) {
349
- console.log("[ToolProtectionService] Skipping empty/invalid fallback config entry", { tool: toolName });
245
+ console.error("[ToolProtectionService] Skipping empty/invalid local config entry", { tool: toolName });
350
246
  }
351
247
  continue;
352
248
  }
353
249
  // Ensure requiredScopes exists (default to empty array if missing)
250
+ // Also preserve optional fields like oauthProvider, riskLevel, authorization
354
251
  const validConfig = {
355
252
  requiresDelegation: localConfig.requiresDelegation ?? false,
356
253
  requiredScopes: localConfig.requiredScopes ?? [],
254
+ ...(localConfig.oauthProvider && {
255
+ oauthProvider: localConfig.oauthProvider,
256
+ }),
257
+ ...(localConfig.riskLevel && {
258
+ riskLevel: localConfig.riskLevel,
259
+ }),
260
+ ...(localConfig.authorization && {
261
+ authorization: localConfig.authorization,
262
+ }),
357
263
  };
358
- // Local config overrides API config for this tool
359
264
  mergedToolProtections[toolName] = validConfig;
360
265
  if (this.config.debug) {
361
- console.log("[ToolProtectionService] Overriding API config with local config", {
266
+ console.error("[ToolProtectionService] Applied local config as base", {
362
267
  tool: toolName,
363
268
  localConfig: validConfig,
364
269
  });
365
270
  }
366
271
  }
367
272
  }
273
+ // Step 2: Apply API config (OVERWRITES local config - remote wins)
274
+ for (const [toolName, apiConfig] of Object.entries(toolProtections)) {
275
+ mergedToolProtections[toolName] = apiConfig;
276
+ if (this.config.debug &&
277
+ this.config.localConfig?.toolProtections?.[toolName]) {
278
+ console.error("[ToolProtectionService] Remote config overrides local config", {
279
+ tool: toolName,
280
+ remoteConfig: apiConfig,
281
+ });
282
+ }
283
+ }
368
284
  const config = {
369
285
  toolProtections: mergedToolProtections,
370
286
  };
@@ -373,7 +289,7 @@ class ToolProtectionService {
373
289
  const cacheExpiration = new Date(Date.now() + ttl);
374
290
  await this.cache.set(cacheKey, config, ttl);
375
291
  // Always log tool count and protection status (critical for debugging)
376
- console.log("[ToolProtectionService] Config loaded from API", {
292
+ console.error("[ToolProtectionService] Config loaded from API", {
377
293
  source: "api",
378
294
  toolCount: Object.keys(mergedToolProtections).length,
379
295
  protectedTools: Object.entries(mergedToolProtections)
@@ -385,7 +301,7 @@ class ToolProtectionService {
385
301
  cacheExpiresAt: cacheExpiration.toISOString(),
386
302
  });
387
303
  if (this.config.debug) {
388
- console.log("[ToolProtectionService] API fetch successful, config cached", {
304
+ console.error("[ToolProtectionService] API fetch successful, config cached", {
389
305
  source: "cache-write",
390
306
  agentDid: agentDid.slice(0, 20) + "...",
391
307
  cacheKey,
@@ -414,11 +330,11 @@ class ToolProtectionService {
414
330
  throw error;
415
331
  }
416
332
  // Re-throw HTTP errors (4xx, 5xx) - these indicate API issues, not network failures
417
- // Exception: 429 (rate limit) should fallback if fallback config is available
333
+ // Exception: 429 (rate limit) should fallback if offline fallback config is available
418
334
  if (errorMessage.includes("Failed to fetch bouncer config:")) {
419
335
  const status = error.status;
420
336
  // Allow 429 to fallback (rate limiting is temporary, fallback is acceptable)
421
- if (status === 429 && this.config.fallbackConfig) {
337
+ if (status === 429 && this.config.offlineFallbackConfig) {
422
338
  // Will fall through to fallback logic below
423
339
  }
424
340
  else {
@@ -449,22 +365,26 @@ class ToolProtectionService {
449
365
  });
450
366
  return staleCache;
451
367
  }
452
- // 5. Fallback to local config (only for network errors, not API errors)
453
- if (this.config.fallbackConfig) {
454
- // Always warn when using fallback (not just debug mode)
455
- console.warn("[ToolProtectionService] API fetch failed, using fallback config", {
368
+ // 5. Fallback to offline config (only used when API is unavailable)
369
+ // Note: offlineFallbackConfig is separate from localConfig
370
+ // - localConfig: Merged with remote (remote wins), used always
371
+ // - offlineFallbackConfig: Only used when API is completely unavailable
372
+ if (this.config.offlineFallbackConfig) {
373
+ // Always warn when using offline fallback (not just debug mode)
374
+ console.warn("[ToolProtectionService] API fetch failed, using offline fallback config", {
456
375
  agentDid: agentDid.slice(0, 20) + "...",
457
376
  error: errorMessage,
458
377
  });
459
378
  // Cache the fallback config to avoid repeated API calls
460
379
  const ttl = this.config.cacheTtl ?? 300000; // Default 5 minutes
461
- await this.cache.set(cacheKey, this.config.fallbackConfig, ttl);
462
- return this.config.fallbackConfig;
380
+ await this.cache.set(cacheKey, this.config.offlineFallbackConfig, ttl);
381
+ return this.config.offlineFallbackConfig;
463
382
  }
464
383
  // 6. Fail-safe behavior: deny-all by default (secure)
384
+ // This is the last resort when: API fails AND no stale cache AND no offlineFallbackConfig
465
385
  const failSafeBehavior = this.config.failSafeBehavior ?? "deny-all";
466
386
  if (failSafeBehavior === "deny-all") {
467
- console.error("[ToolProtectionService] API fetch failed, no fallback, failing closed (deny-all)", {
387
+ console.error("[ToolProtectionService] API fetch failed, no offline fallback, failing closed (deny-all)", {
468
388
  agentDid: agentDid.slice(0, 20) + "...",
469
389
  error: errorMessage,
470
390
  cacheKey,
@@ -485,7 +405,7 @@ class ToolProtectionService {
485
405
  }
486
406
  else {
487
407
  // failSafeBehavior === 'allow-all' (insecure, not recommended)
488
- console.warn("[ToolProtectionService] API fetch failed, no fallback, failing open (allow-all)", {
408
+ console.warn("[ToolProtectionService] API fetch failed, no offline fallback, failing open (allow-all)", {
489
409
  agentDid: agentDid.slice(0, 20) + "...",
490
410
  error: errorMessage,
491
411
  cacheKey,
@@ -515,7 +435,7 @@ class ToolProtectionService {
515
435
  }
516
436
  // Always log the check result (critical for debugging)
517
437
  if (this.config.debug || !protection || protection.requiresDelegation) {
518
- console.log("[ToolProtectionService] Protection check", {
438
+ console.error("[ToolProtectionService] Protection check", {
519
439
  tool: toolName,
520
440
  agentDid: agentDid.slice(0, 20) + "...",
521
441
  found: !!protection,
@@ -579,7 +499,7 @@ class ToolProtectionService {
579
499
  : "(empty)";
580
500
  const apiKeyLength = this.config.apiKey?.length || 0;
581
501
  if (this.config.debug) {
582
- console.log("[ToolProtectionService] Fetching from API:", url, {
502
+ console.error("[ToolProtectionService] Fetching from API:", url, {
583
503
  method: useMergedEndpoint
584
504
  ? "projects/{projectId}/config (merged)"
585
505
  : "config?agent_did (legacy)",
@@ -641,20 +561,53 @@ class ToolProtectionService {
641
561
  if (!data.success) {
642
562
  throw new Error("API returned success: false");
643
563
  }
644
- // Transform merged config format to normalized format
645
- // If response contains config.toolProtection.tools, extract them to data.toolProtections
646
- if (useMergedEndpoint && data.data.config?.toolProtection?.tools) {
647
- // Extract embedded tools to the standard toolProtections field
648
- data.data.toolProtections = data.data.config.toolProtection.tools;
649
- if (this.config.debug) {
650
- console.log("[ToolProtectionService] Extracted tools from merged config", {
651
- toolCount: Object.keys(data.data.toolProtections).length,
652
- tools: Object.keys(data.data.toolProtections),
653
- });
654
- }
655
- }
656
564
  return data;
657
565
  }
566
+ /**
567
+ * Parse tool protections from API response
568
+ *
569
+ * Extracts tool protection configs from merged config format:
570
+ * { data: { config: { toolProtection: { tools: {...} } } } }
571
+ *
572
+ * @param response API response from AgentShield
573
+ * @returns Record of tool name to ToolProtection
574
+ */
575
+ parseToolProtectionsFromResponse(response) {
576
+ const toolProtections = {};
577
+ // Extract from merged config format: data.config.toolProtection.tools
578
+ const tools = response.data.config?.toolProtection?.tools;
579
+ if (!tools) {
580
+ return toolProtections;
581
+ }
582
+ for (const [toolName, toolConfig] of Object.entries(tools)) {
583
+ // Support both camelCase and snake_case for backward compatibility with DB data
584
+ const requiresDelegation = toolConfig.requiresDelegation ??
585
+ toolConfig.requires_delegation ??
586
+ false;
587
+ const requiredScopes = toolConfig.requiredScopes ??
588
+ toolConfig.required_scopes ??
589
+ toolConfig.scopes ??
590
+ [];
591
+ const oauthProvider = toolConfig.oauthProvider ?? toolConfig.oauth_provider ?? undefined;
592
+ const rawRiskLevel = toolConfig.riskLevel ?? toolConfig.risk_level ?? undefined;
593
+ // Type-safe cast for riskLevel enum
594
+ const riskLevel = rawRiskLevel &&
595
+ ["low", "medium", "high", "critical"].includes(rawRiskLevel)
596
+ ? rawRiskLevel
597
+ : undefined;
598
+ // Cast authorization to proper discriminated union type
599
+ // The API returns the correct shape, we just need to help TypeScript
600
+ const authorization = toolConfig.authorization;
601
+ toolProtections[toolName] = {
602
+ requiresDelegation,
603
+ requiredScopes,
604
+ ...(oauthProvider && { oauthProvider }),
605
+ ...(riskLevel && { riskLevel }),
606
+ ...(authorization && { authorization }),
607
+ };
608
+ }
609
+ return toolProtections;
610
+ }
658
611
  /**
659
612
  * Clear the cache for a project or agent (useful for testing or manual refresh)
660
613
  *
@@ -669,7 +622,7 @@ class ToolProtectionService {
669
622
  ? `config:tool-protections:${this.config.projectId}`
670
623
  : `agent:${agentDid}`;
671
624
  if (this.config.debug) {
672
- console.log("[ToolProtectionService] Clearing cache", {
625
+ console.error("[ToolProtectionService] Clearing cache", {
673
626
  cacheKey,
674
627
  projectId: this.config.projectId || "none",
675
628
  agentDid: agentDid.slice(0, 20) + "...",
@@ -701,105 +654,64 @@ class ToolProtectionService {
701
654
  const cacheKey = this.config.projectId
702
655
  ? `config:tool-protections:${this.config.projectId}`
703
656
  : `agent:${agentDid}`;
704
- console.log("[ToolProtectionService] clearAndRefresh starting", {
657
+ console.error("[ToolProtectionService] clearAndRefresh starting", {
705
658
  cacheKey,
706
659
  projectId: this.config.projectId || "none",
707
660
  agentDid: agentDid.slice(0, 20) + "...",
708
661
  });
709
662
  // 1. Delete the cache entry
710
663
  await this.cache.delete(cacheKey);
711
- console.log("[ToolProtectionService] Cache entry deleted", { cacheKey });
664
+ console.error("[ToolProtectionService] Cache entry deleted", { cacheKey });
712
665
  // 2. Fetch fresh config from API with CDN cache bypass
713
666
  // This ensures we get fresh data from origin, not stale CDN data
714
667
  try {
715
668
  const response = await this.fetchFromApi(agentDid, {
716
669
  bypassCDNCache: true,
717
670
  });
718
- // Transform API response to internal format (same logic as getToolProtectionConfig)
719
- const toolProtections = {};
720
- if (response.data.toolProtections) {
721
- for (const [toolName, toolConfig] of Object.entries(response.data.toolProtections)) {
722
- const requiresDelegation = toolConfig.requiresDelegation ??
723
- toolConfig.requires_delegation ??
724
- false;
725
- const requiredScopes = toolConfig.requiredScopes ??
726
- toolConfig.required_scopes ??
727
- toolConfig.scopes ??
728
- [];
729
- const oauthProvider = toolConfig.oauthProvider ??
730
- toolConfig.oauth_provider ??
731
- undefined;
732
- const riskLevel = toolConfig.riskLevel ??
733
- toolConfig.risk_level ??
734
- undefined;
735
- toolProtections[toolName] = {
736
- requiresDelegation,
737
- requiredScopes,
738
- ...(oauthProvider && { oauthProvider }),
739
- ...(riskLevel && { riskLevel }),
740
- };
741
- }
742
- }
743
- else if (response.data.tools) {
744
- if (Array.isArray(response.data.tools)) {
745
- for (const tool of response.data.tools) {
746
- const toolName = tool.name;
747
- if (!toolName)
748
- continue;
749
- const requiresDelegation = tool.requiresDelegation ??
750
- tool.requires_delegation ??
751
- false;
752
- const requiredScopes = tool.requiredScopes ??
753
- tool.required_scopes ??
754
- tool.scopes ??
755
- [];
756
- const oauthProvider = tool.oauthProvider ??
757
- tool.oauth_provider ??
758
- undefined;
759
- const riskLevel = tool.riskLevel ?? tool.risk_level ?? undefined;
760
- toolProtections[toolName] = {
761
- requiresDelegation,
762
- requiredScopes,
763
- ...(oauthProvider && { oauthProvider }),
764
- ...(riskLevel && { riskLevel }),
765
- };
766
- }
767
- }
768
- else {
769
- for (const [toolName, toolConfig] of Object.entries(response.data.tools)) {
770
- const requiresDelegation = toolConfig.requiresDelegation ??
771
- toolConfig.requires_delegation ??
772
- false;
773
- const requiredScopes = toolConfig.requiredScopes ??
774
- toolConfig.required_scopes ??
775
- toolConfig.scopes ??
776
- [];
777
- const oauthProvider = toolConfig.oauthProvider ??
778
- toolConfig.oauth_provider ??
779
- undefined;
780
- const riskLevel = toolConfig.riskLevel ??
781
- toolConfig.risk_level ??
782
- undefined;
783
- toolProtections[toolName] = {
784
- requiresDelegation,
785
- requiredScopes,
786
- ...(oauthProvider && { oauthProvider }),
787
- ...(riskLevel && { riskLevel }),
671
+ // Use shared parsing logic for consistency
672
+ const toolProtections = this.parseToolProtectionsFromResponse(response);
673
+ // Apply same merge logic as getToolProtectionConfig:
674
+ // Step 1: Apply localConfig as base (if provided)
675
+ // Step 2: Apply API config (OVERWRITES local config - remote wins)
676
+ const mergedToolProtections = {};
677
+ // Step 1: Apply local config as base
678
+ if (this.config.localConfig?.toolProtections) {
679
+ for (const [toolName, localConfig] of Object.entries(this.config.localConfig.toolProtections)) {
680
+ if (localConfig &&
681
+ typeof localConfig === "object" &&
682
+ Object.keys(localConfig).length > 0) {
683
+ const validConfig = {
684
+ requiresDelegation: localConfig.requiresDelegation ?? false,
685
+ requiredScopes: localConfig.requiredScopes ?? [],
686
+ ...(localConfig.oauthProvider && {
687
+ oauthProvider: localConfig.oauthProvider,
688
+ }),
689
+ ...(localConfig.riskLevel && {
690
+ riskLevel: localConfig.riskLevel,
691
+ }),
692
+ ...(localConfig.authorization && {
693
+ authorization: localConfig.authorization,
694
+ }),
788
695
  };
696
+ mergedToolProtections[toolName] = validConfig;
789
697
  }
790
698
  }
791
699
  }
700
+ // Step 2: Apply API config (OVERWRITES local config - remote wins)
701
+ for (const [toolName, apiConfig] of Object.entries(toolProtections)) {
702
+ mergedToolProtections[toolName] = apiConfig;
703
+ }
792
704
  // Construct fresh config (ToolProtectionConfig type)
793
705
  const freshConfig = {
794
- toolProtections,
706
+ toolProtections: mergedToolProtections,
795
707
  };
796
708
  // 3. Write fresh config to cache
797
709
  const ttl = this.config.cacheTtl ?? 300000;
798
710
  await this.cache.set(cacheKey, freshConfig, ttl);
799
- console.log("[ToolProtectionService] Fresh config fetched and cached", {
711
+ console.error("[ToolProtectionService] Fresh config fetched and cached", {
800
712
  cacheKey,
801
- toolCount: Object.keys(toolProtections).length,
802
- protectedTools: Object.entries(toolProtections)
713
+ toolCount: Object.keys(mergedToolProtections).length,
714
+ protectedTools: Object.entries(mergedToolProtections)
803
715
  .filter(([_, cfg]) => cfg.requiresDelegation)
804
716
  .map(([name]) => name),
805
717
  source: "api",
@@ -807,16 +719,16 @@ class ToolProtectionService {
807
719
  return { config: freshConfig, cacheKey, source: "api" };
808
720
  }
809
721
  catch (error) {
810
- console.warn("[ToolProtectionService] API fetch failed during refresh, using fallback", {
722
+ console.warn("[ToolProtectionService] API fetch failed during refresh, using offline fallback", {
811
723
  error: error instanceof Error ? error.message : String(error),
812
724
  cacheKey,
813
725
  });
814
- // Use fallback config if API fails
815
- const fallbackConfig = this.config
816
- .fallbackConfig || {
726
+ // Use offline fallback config if API fails
727
+ const offlineFallback = this.config
728
+ .offlineFallbackConfig || {
817
729
  toolProtections: {},
818
730
  };
819
- return { config: fallbackConfig, cacheKey, source: "fallback" };
731
+ return { config: offlineFallback, cacheKey, source: "fallback" };
820
732
  }
821
733
  }
822
734
  }
@@ -46,17 +46,40 @@ export interface ToolProtectionServiceConfig {
46
46
  */
47
47
  cacheTtl?: number;
48
48
  /**
49
- * Fallback configuration to use if API is unavailable
49
+ * Local/default configuration (LOWEST priority after defaults)
50
+ *
51
+ * This config serves as a base that gets OVERRIDDEN by remote config.
52
+ * Use this for development defaults or project-specific base settings.
53
+ *
54
+ * Precedence order (highest to lowest):
55
+ * 1. Remote config (AgentShield API) - WINS
56
+ * 2. Local config (this field)
57
+ * 3. Framework defaults
58
+ *
50
59
  * @optional
51
60
  */
52
- fallbackConfig?: ToolProtectionConfig;
61
+ localConfig?: ToolProtectionConfig;
53
62
  /**
54
- * Fail-safe behavior when API is unavailable and no fallback is provided
63
+ * Offline fallback configuration (ONLY used when API is unavailable)
64
+ *
65
+ * Unlike localConfig, this is NOT merged with remote config.
66
+ * It's only used as a complete replacement when the API cannot be reached.
67
+ *
68
+ * Fallback order when API fails:
69
+ * 1. Stale cache (if allowStaleCache=true and within maxStaleCacheAge)
70
+ * 2. This offlineFallbackConfig
71
+ * 3. failSafeBehavior (deny-all or allow-all)
72
+ *
73
+ * @optional
74
+ */
75
+ offlineFallbackConfig?: ToolProtectionConfig;
76
+ /**
77
+ * Fail-safe behavior when API is unavailable and no offline fallback is provided
55
78
  * - 'deny-all': Block all tools by default (secure, recommended for production)
56
79
  * - 'allow-all': Allow all tools (insecure, not recommended for production)
57
80
  * @default 'deny-all'
58
81
  */
59
- failSafeBehavior?: 'deny-all' | 'allow-all';
82
+ failSafeBehavior?: "deny-all" | "allow-all";
60
83
  /**
61
84
  * Maximum age (in milliseconds) for stale cache to be used during outages
62
85
  * Stale cache is used when API fails and no fallback is provided