@kya-os/mcp-i-core 1.3.7-canary.0 → 1.3.7-canary.clientinfo.20251126041014
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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test$colon$coverage.log +4239 -0
- package/.turbo/turbo-test.log +2973 -0
- package/COMPLIANCE_IMPROVEMENT_REPORT.md +483 -0
- package/Composer 3.md +615 -0
- package/GPT-5.md +1169 -0
- package/OPUS-plan.md +352 -0
- package/PHASE_3_AND_4.1_SUMMARY.md +585 -0
- package/PHASE_3_SUMMARY.md +317 -0
- package/PHASE_4.1.3_SUMMARY.md +428 -0
- package/PHASE_4.1_COMPLETE.md +525 -0
- package/PHASE_4_USER_DID_IDENTITY_LINKING_PLAN.md +1240 -0
- package/SCHEMA_COMPLIANCE_REPORT.md +275 -0
- package/TEST_PLAN.md +571 -0
- package/coverage/coverage-final.json +57 -0
- package/dist/__tests__/utils/mock-providers.d.ts +1 -2
- package/dist/__tests__/utils/mock-providers.d.ts.map +1 -1
- package/dist/__tests__/utils/mock-providers.js.map +1 -1
- package/dist/cache/oauth-config-cache.d.ts +69 -0
- package/dist/cache/oauth-config-cache.d.ts.map +1 -0
- package/dist/cache/oauth-config-cache.js +76 -0
- package/dist/cache/oauth-config-cache.js.map +1 -0
- package/dist/identity/idp-token-resolver.d.ts +53 -0
- package/dist/identity/idp-token-resolver.d.ts.map +1 -0
- package/dist/identity/idp-token-resolver.js +108 -0
- package/dist/identity/idp-token-resolver.js.map +1 -0
- package/dist/identity/idp-token-storage.interface.d.ts +42 -0
- package/dist/identity/idp-token-storage.interface.d.ts.map +1 -0
- package/dist/identity/idp-token-storage.interface.js +12 -0
- package/dist/identity/idp-token-storage.interface.js.map +1 -0
- package/dist/identity/user-did-manager.d.ts +39 -1
- package/dist/identity/user-did-manager.d.ts.map +1 -1
- package/dist/identity/user-did-manager.js +69 -3
- package/dist/identity/user-did-manager.js.map +1 -1
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +43 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/audit-logger.d.ts +37 -0
- package/dist/runtime/audit-logger.d.ts.map +1 -0
- package/dist/runtime/audit-logger.js +9 -0
- package/dist/runtime/audit-logger.js.map +1 -0
- package/dist/runtime/base.d.ts +19 -2
- package/dist/runtime/base.d.ts.map +1 -1
- package/dist/runtime/base.js +227 -11
- package/dist/runtime/base.js.map +1 -1
- package/dist/services/access-control.service.d.ts.map +1 -1
- package/dist/services/access-control.service.js +199 -15
- package/dist/services/access-control.service.js.map +1 -1
- package/dist/services/authorization/authorization-registry.d.ts +29 -0
- package/dist/services/authorization/authorization-registry.d.ts.map +1 -0
- package/dist/services/authorization/authorization-registry.js +57 -0
- package/dist/services/authorization/authorization-registry.js.map +1 -0
- package/dist/services/authorization/types.d.ts +53 -0
- package/dist/services/authorization/types.d.ts.map +1 -0
- package/dist/services/authorization/types.js +10 -0
- package/dist/services/authorization/types.js.map +1 -0
- package/dist/services/batch-delegation.service.d.ts +53 -0
- package/dist/services/batch-delegation.service.d.ts.map +1 -0
- package/dist/services/batch-delegation.service.js +95 -0
- package/dist/services/batch-delegation.service.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +4 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/oauth-config.service.d.ts +53 -0
- package/dist/services/oauth-config.service.d.ts.map +1 -0
- package/dist/services/oauth-config.service.js +141 -0
- package/dist/services/oauth-config.service.js.map +1 -0
- package/dist/services/oauth-provider-registry.d.ts +88 -0
- package/dist/services/oauth-provider-registry.d.ts.map +1 -0
- package/dist/services/oauth-provider-registry.js +128 -0
- package/dist/services/oauth-provider-registry.js.map +1 -0
- package/dist/services/oauth-service.d.ts +77 -0
- package/dist/services/oauth-service.d.ts.map +1 -0
- package/dist/services/oauth-service.js +373 -0
- package/dist/services/oauth-service.js.map +1 -0
- package/dist/services/oauth-token-retrieval.service.d.ts +49 -0
- package/dist/services/oauth-token-retrieval.service.d.ts.map +1 -0
- package/dist/services/oauth-token-retrieval.service.js +150 -0
- package/dist/services/oauth-token-retrieval.service.js.map +1 -0
- package/dist/services/provider-resolver.d.ts +48 -0
- package/dist/services/provider-resolver.d.ts.map +1 -0
- package/dist/services/provider-resolver.js +121 -0
- package/dist/services/provider-resolver.js.map +1 -0
- package/dist/services/provider-validator.d.ts +55 -0
- package/dist/services/provider-validator.d.ts.map +1 -0
- package/dist/services/provider-validator.js +135 -0
- package/dist/services/provider-validator.js.map +1 -0
- package/dist/services/session-registration.service.d.ts +80 -0
- package/dist/services/session-registration.service.d.ts.map +1 -0
- package/dist/services/session-registration.service.js +228 -0
- package/dist/services/session-registration.service.js.map +1 -0
- package/dist/services/tool-context-builder.d.ts +57 -0
- package/dist/services/tool-context-builder.d.ts.map +1 -0
- package/dist/services/tool-context-builder.js +125 -0
- package/dist/services/tool-context-builder.js.map +1 -0
- package/dist/services/tool-protection.service.d.ts +27 -0
- package/dist/services/tool-protection.service.d.ts.map +1 -1
- package/dist/services/tool-protection.service.js +194 -4
- package/dist/services/tool-protection.service.js.map +1 -1
- package/dist/types/oauth-required-error.d.ts +40 -0
- package/dist/types/oauth-required-error.d.ts.map +1 -0
- package/dist/types/oauth-required-error.js +40 -0
- package/dist/types/oauth-required-error.js.map +1 -0
- package/dist/utils/did-helpers.d.ts +33 -0
- package/dist/utils/did-helpers.d.ts.map +1 -1
- package/dist/utils/did-helpers.js +40 -0
- package/dist/utils/did-helpers.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/docs/API_REFERENCE.md +1362 -0
- package/docs/COMPLIANCE_MATRIX.md +691 -0
- package/docs/STATUSLIST2021_GUIDE.md +696 -0
- package/docs/W3C_VC_DELEGATION_GUIDE.md +710 -0
- package/package.json +23 -54
- package/scripts/audit-compliance.ts +724 -0
- package/src/__tests__/cache/tool-protection-cache.test.ts +640 -0
- package/src/__tests__/config/provider-runtime-config.test.ts +309 -0
- package/src/__tests__/delegation-e2e.test.ts +690 -0
- package/src/__tests__/identity/user-did-manager.test.ts +213 -0
- package/src/__tests__/index.test.ts +56 -0
- package/src/__tests__/integration/full-flow.test.ts +776 -0
- package/src/__tests__/integration.test.ts +281 -0
- package/src/__tests__/providers/base.test.ts +173 -0
- package/src/__tests__/providers/memory.test.ts +319 -0
- package/src/__tests__/regression/phase2-regression.test.ts +429 -0
- package/src/__tests__/runtime/audit-logger.test.ts +154 -0
- package/src/__tests__/runtime/base-extensions.test.ts +593 -0
- package/src/__tests__/runtime/base.test.ts +869 -0
- package/src/__tests__/runtime/delegation-flow.test.ts +164 -0
- package/src/__tests__/runtime/proof-client-did.test.ts +375 -0
- package/src/__tests__/runtime/route-interception.test.ts +686 -0
- package/src/__tests__/runtime/tool-protection-enforcement.test.ts +908 -0
- package/src/__tests__/services/agentshield-integration.test.ts +784 -0
- package/src/__tests__/services/cache-busting.test.ts +125 -0
- package/src/__tests__/services/oauth-service-pkce.test.ts +556 -0
- package/src/__tests__/services/provider-resolver-edge-cases.test.ts +591 -0
- package/src/__tests__/services/tool-protection-oauth-provider.test.ts +480 -0
- package/src/__tests__/services/tool-protection.service.test.ts +1366 -0
- package/src/__tests__/utils/mock-providers.ts +340 -0
- package/src/cache/oauth-config-cache.d.ts +69 -0
- package/src/cache/oauth-config-cache.d.ts.map +1 -0
- package/src/cache/oauth-config-cache.js.map +1 -0
- package/src/cache/oauth-config-cache.ts +123 -0
- package/src/cache/tool-protection-cache.ts +171 -0
- package/src/compliance/EXAMPLE.md +412 -0
- package/src/compliance/__tests__/schema-verifier.test.ts +797 -0
- package/src/compliance/index.ts +8 -0
- package/src/compliance/schema-registry.ts +460 -0
- package/src/compliance/schema-verifier.ts +708 -0
- package/src/config/__tests__/remote-config.spec.ts +268 -0
- package/src/config/remote-config.ts +174 -0
- package/src/config.ts +309 -0
- package/src/delegation/__tests__/audience-validator.test.ts +112 -0
- package/src/delegation/__tests__/bitstring.test.ts +346 -0
- package/src/delegation/__tests__/cascading-revocation.test.ts +628 -0
- package/src/delegation/__tests__/delegation-graph.test.ts +584 -0
- package/src/delegation/__tests__/utils.test.ts +152 -0
- package/src/delegation/__tests__/vc-issuer.test.ts +442 -0
- package/src/delegation/__tests__/vc-verifier.test.ts +922 -0
- package/src/delegation/audience-validator.ts +52 -0
- package/src/delegation/bitstring.ts +278 -0
- package/src/delegation/cascading-revocation.ts +370 -0
- package/src/delegation/delegation-graph.ts +299 -0
- package/src/delegation/index.ts +14 -0
- package/src/delegation/statuslist-manager.ts +353 -0
- package/src/delegation/storage/__tests__/memory-graph-storage.test.ts +366 -0
- package/src/delegation/storage/__tests__/memory-statuslist-storage.test.ts +228 -0
- package/src/delegation/storage/index.ts +9 -0
- package/src/delegation/storage/memory-graph-storage.ts +178 -0
- package/src/delegation/storage/memory-statuslist-storage.ts +77 -0
- package/src/delegation/utils.ts +42 -0
- package/src/delegation/vc-issuer.ts +232 -0
- package/src/delegation/vc-verifier.ts +568 -0
- package/src/identity/idp-token-resolver.ts +147 -0
- package/src/identity/idp-token-storage.interface.ts +59 -0
- package/src/identity/user-did-manager.ts +370 -0
- package/src/index.ts +271 -0
- package/src/providers/base.d.ts +91 -0
- package/src/providers/base.d.ts.map +1 -0
- package/src/providers/base.js.map +1 -0
- package/src/providers/base.ts +96 -0
- package/src/providers/memory.ts +142 -0
- package/src/runtime/audit-logger.ts +39 -0
- package/src/runtime/base.ts +1329 -0
- package/src/services/__tests__/access-control.integration.test.ts +443 -0
- package/src/services/__tests__/access-control.proof-response-validation.test.ts +578 -0
- package/src/services/__tests__/access-control.service.test.ts +970 -0
- package/src/services/__tests__/batch-delegation.service.test.ts +351 -0
- package/src/services/__tests__/crypto.service.test.ts +531 -0
- package/src/services/__tests__/oauth-provider-registry.test.ts +142 -0
- package/src/services/__tests__/proof-verifier.integration.test.ts +485 -0
- package/src/services/__tests__/proof-verifier.test.ts +489 -0
- package/src/services/__tests__/provider-resolution.integration.test.ts +202 -0
- package/src/services/__tests__/provider-resolver.test.ts +213 -0
- package/src/services/__tests__/storage.service.test.ts +358 -0
- package/src/services/access-control.service.ts +990 -0
- package/src/services/authorization/authorization-registry.ts +66 -0
- package/src/services/authorization/types.ts +71 -0
- package/src/services/batch-delegation.service.ts +137 -0
- package/src/services/crypto.service.ts +302 -0
- package/src/services/errors.ts +76 -0
- package/src/services/index.ts +18 -0
- package/src/services/oauth-config.service.d.ts +53 -0
- package/src/services/oauth-config.service.d.ts.map +1 -0
- package/src/services/oauth-config.service.js.map +1 -0
- package/src/services/oauth-config.service.ts +192 -0
- package/src/services/oauth-provider-registry.d.ts +57 -0
- package/src/services/oauth-provider-registry.d.ts.map +1 -0
- package/src/services/oauth-provider-registry.js.map +1 -0
- package/src/services/oauth-provider-registry.ts +141 -0
- package/src/services/oauth-service.ts +544 -0
- package/src/services/oauth-token-retrieval.service.ts +245 -0
- package/src/services/proof-verifier.ts +478 -0
- package/src/services/provider-resolver.d.ts +48 -0
- package/src/services/provider-resolver.d.ts.map +1 -0
- package/src/services/provider-resolver.js.map +1 -0
- package/src/services/provider-resolver.ts +146 -0
- package/src/services/provider-validator.ts +170 -0
- package/src/services/session-registration.service.ts +317 -0
- package/src/services/storage.service.ts +566 -0
- package/src/services/tool-context-builder.ts +172 -0
- package/src/services/tool-protection.service.ts +982 -0
- package/src/types/oauth-required-error.ts +63 -0
- package/src/types/tool-protection.ts +155 -0
- package/src/utils/__tests__/did-helpers.test.ts +101 -0
- package/src/utils/base64.ts +148 -0
- package/src/utils/cors.ts +83 -0
- package/src/utils/did-helpers.ts +150 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/storage-keys.ts +278 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +56 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToolProtectionService - Fetches and caches tool protection configurations
|
|
3
|
+
*
|
|
4
|
+
* This service manages tool protection configuration from AgentShield API with
|
|
5
|
+
* efficient caching and automatic synchronization support.
|
|
6
|
+
*
|
|
7
|
+
* CORE FUNCTIONALITY:
|
|
8
|
+
* -------------------
|
|
9
|
+
* 1. Fetches tool protection config from AgentShield API
|
|
10
|
+
* 2. Caches responses with configurable TTL (default 5 minutes)
|
|
11
|
+
* 3. Falls back to local config if API unavailable
|
|
12
|
+
* 4. Provides delegation requirement checking before tool execution
|
|
13
|
+
*
|
|
14
|
+
* SYNCHRONIZATION WITH AGENTSHIELD:
|
|
15
|
+
* ----------------------------------
|
|
16
|
+
* When you update tool protection settings in the AgentShield dashboard:
|
|
17
|
+
*
|
|
18
|
+
* 1. Dashboard sends PATCH /api/internal/bouncer/tools/{projectId}/{toolName}
|
|
19
|
+
* 2. AgentShield updates the database immediately (PostgreSQL JSONB column)
|
|
20
|
+
* 3. Dashboard sends POST /admin/clear-cache to this service (automatic)
|
|
21
|
+
* 4. This service clears the cached config from KV storage
|
|
22
|
+
* 5. Next tool call fetches fresh config from AgentShield API
|
|
23
|
+
* 6. New config is cached for the configured TTL period
|
|
24
|
+
*
|
|
25
|
+
* CACHE INVALIDATION:
|
|
26
|
+
* -------------------
|
|
27
|
+
* Cache is invalidated via POST /admin/clear-cache endpoint:
|
|
28
|
+
* - Triggered automatically by AgentShield dashboard when tool protection changes
|
|
29
|
+
* - Can be triggered manually for testing/debugging
|
|
30
|
+
* - Requires API key authentication for security
|
|
31
|
+
*
|
|
32
|
+
* If cache is NOT cleared:
|
|
33
|
+
* - Stale config is served until TTL expires (default 5 minutes)
|
|
34
|
+
* - Configure shorter TTL via TOOL_PROTECTION_CACHE_TTL env var for faster updates
|
|
35
|
+
* - Set to 0 for no cache (not recommended for production)
|
|
36
|
+
*
|
|
37
|
+
* TOOL DISCOVERY PREREQUISITE:
|
|
38
|
+
* ----------------------------
|
|
39
|
+
* IMPORTANT: Tools must be discovered before they can be protected!
|
|
40
|
+
*
|
|
41
|
+
* Discovery happens when:
|
|
42
|
+
* - Agent makes first tool call with proof submission
|
|
43
|
+
* - AgentShield extracts tool info from cryptographic proof
|
|
44
|
+
* - Tool is added to bouncerConfigs.discoveredTools in database
|
|
45
|
+
*
|
|
46
|
+
* If tool not discovered:
|
|
47
|
+
* - Tool won't appear in dashboard
|
|
48
|
+
* - Protection settings can't be configured
|
|
49
|
+
* - GET /tool-protections returns empty object
|
|
50
|
+
*
|
|
51
|
+
* DEBUGGING:
|
|
52
|
+
* ----------
|
|
53
|
+
* Enable debug logging with:
|
|
54
|
+
* toolProtection: { debug: true }
|
|
55
|
+
*
|
|
56
|
+
* Debug logs show:
|
|
57
|
+
* - Cache hits vs API fetches
|
|
58
|
+
* - Full API responses
|
|
59
|
+
* - Tool protection status for each tool
|
|
60
|
+
* - Cache TTL and expiration times
|
|
61
|
+
* - Source of config data (cache, api, or fallback)
|
|
62
|
+
*
|
|
63
|
+
* TROUBLESHOOTING:
|
|
64
|
+
* ----------------
|
|
65
|
+
* Problem: Dashboard shows protection but tool still executes
|
|
66
|
+
* Cause: Stale cache not invalidated
|
|
67
|
+
* Solution: POST /admin/clear-cache or wait for TTL expiration
|
|
68
|
+
*
|
|
69
|
+
* Problem: Empty toolProtections returned from API
|
|
70
|
+
* Cause: Tool not discovered yet (no proof submissions)
|
|
71
|
+
* Solution: Make at least one tool call to trigger discovery
|
|
72
|
+
*
|
|
73
|
+
* Problem: Updates take 5+ minutes to apply
|
|
74
|
+
* Cause: Long cache TTL and cache clear failed
|
|
75
|
+
* Solution: Configure MCP server URL in AgentShield for auto cache clear
|
|
76
|
+
*
|
|
77
|
+
* @see https://github.com/modelcontextprotocol-identity/agent-shield/docs/bouncer/tool-protection-sync.md
|
|
78
|
+
* @package @kya-os/mcp-i-core
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
import type {
|
|
82
|
+
ToolProtection,
|
|
83
|
+
ToolProtectionConfig,
|
|
84
|
+
ToolProtectionServiceConfig,
|
|
85
|
+
DelegationRequiredError,
|
|
86
|
+
} from "../types/tool-protection.js";
|
|
87
|
+
import type { ToolProtectionCache } from "../cache/tool-protection-cache.js";
|
|
88
|
+
import { InMemoryToolProtectionCache } from "../cache/tool-protection-cache.js";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Response from AgentShield API bouncer endpoints
|
|
92
|
+
*
|
|
93
|
+
* Supports multiple endpoint formats:
|
|
94
|
+
* 1. New endpoint (/projects/{projectId}/tool-protections): { data: { toolProtections: { [toolName]: {...} } } } }
|
|
95
|
+
* 2. Old endpoint (/config?agent_did=...): { data: { tools: [{ name: string, ... }] } }
|
|
96
|
+
* 3. Legacy format: { data: { tools: { [toolName]: {...} } } }
|
|
97
|
+
*/
|
|
98
|
+
interface BouncerConfigApiResponse {
|
|
99
|
+
success: boolean;
|
|
100
|
+
data: {
|
|
101
|
+
agent_did?: string;
|
|
102
|
+
// New endpoint format: toolProtections object
|
|
103
|
+
toolProtections?: Record<
|
|
104
|
+
string,
|
|
105
|
+
{
|
|
106
|
+
requiresDelegation?: boolean;
|
|
107
|
+
requires_delegation?: boolean;
|
|
108
|
+
requiredScopes?: string[];
|
|
109
|
+
required_scopes?: string[];
|
|
110
|
+
scopes?: string[];
|
|
111
|
+
riskLevel?: string;
|
|
112
|
+
risk_level?: string;
|
|
113
|
+
oauthProvider?: string; // Phase 2: Tool-specific OAuth provider
|
|
114
|
+
oauth_provider?: string; // Phase 2: snake_case variant
|
|
115
|
+
}
|
|
116
|
+
>;
|
|
117
|
+
// Old endpoint format: tools array or object
|
|
118
|
+
tools?:
|
|
119
|
+
| Array<{
|
|
120
|
+
name: string;
|
|
121
|
+
requiresDelegation?: boolean;
|
|
122
|
+
requires_delegation?: boolean;
|
|
123
|
+
scopes?: string[];
|
|
124
|
+
required_scopes?: string[];
|
|
125
|
+
oauthProvider?: string; // Phase 2: Tool-specific OAuth provider
|
|
126
|
+
oauth_provider?: string; // Phase 2: snake_case variant
|
|
127
|
+
}>
|
|
128
|
+
| Record<
|
|
129
|
+
string,
|
|
130
|
+
{
|
|
131
|
+
requiresDelegation?: boolean;
|
|
132
|
+
requires_delegation?: boolean;
|
|
133
|
+
scopes?: string[];
|
|
134
|
+
required_scopes?: string[];
|
|
135
|
+
oauthProvider?: string; // Phase 2: Tool-specific OAuth provider
|
|
136
|
+
oauth_provider?: string; // Phase 2: snake_case variant
|
|
137
|
+
}
|
|
138
|
+
>;
|
|
139
|
+
reputation_threshold?: number;
|
|
140
|
+
denied_agents?: string[];
|
|
141
|
+
};
|
|
142
|
+
metadata?: {
|
|
143
|
+
requestId?: string;
|
|
144
|
+
timestamp?: string;
|
|
145
|
+
cachedUntil?: string;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Service for fetching and checking tool protection configurations
|
|
151
|
+
*/
|
|
152
|
+
export class ToolProtectionService {
|
|
153
|
+
private config: ToolProtectionServiceConfig;
|
|
154
|
+
private cache: ToolProtectionCache;
|
|
155
|
+
|
|
156
|
+
constructor(config: ToolProtectionServiceConfig, cache: ToolProtectionCache) {
|
|
157
|
+
this.config = config;
|
|
158
|
+
this.cache = cache;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get the project ID from the service configuration
|
|
163
|
+
* @returns Project ID or undefined if not configured
|
|
164
|
+
*/
|
|
165
|
+
getProjectId(): string | undefined {
|
|
166
|
+
return this.config.projectId;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get stale cache entry if available and within maxStaleCacheAge
|
|
171
|
+
* Only works with InMemoryToolProtectionCache (has internal access to expiresAt)
|
|
172
|
+
*
|
|
173
|
+
* NOTE: This checks stale cache BEFORE calling cache.get() to avoid deletion of expired entries
|
|
174
|
+
*
|
|
175
|
+
* @param cacheKey Cache key to check
|
|
176
|
+
* @returns Stale cache entry or null if not available/too old
|
|
177
|
+
*/
|
|
178
|
+
private async getStaleCache(
|
|
179
|
+
cacheKey: string
|
|
180
|
+
): Promise<ToolProtectionConfig | null> {
|
|
181
|
+
// Only check stale cache if enabled
|
|
182
|
+
if (this.config.allowStaleCache === false) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Only works with InMemoryToolProtectionCache
|
|
187
|
+
if (!(this.cache instanceof InMemoryToolProtectionCache)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Use public method to get stale cache entry
|
|
192
|
+
const staleConfig = this.cache.getStale(cacheKey);
|
|
193
|
+
if (!staleConfig) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Check if stale cache is within maxStaleCacheAge
|
|
198
|
+
const expiresAt = this.cache.getExpiresAt(cacheKey);
|
|
199
|
+
if (!expiresAt) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
const maxStaleAge = this.config.maxStaleCacheAge ?? 86400000; // Default 24 hours
|
|
205
|
+
|
|
206
|
+
// Check if expired but within maxStaleCacheAge
|
|
207
|
+
if (now > expiresAt && now - expiresAt <= maxStaleAge) {
|
|
208
|
+
if (this.config.debug) {
|
|
209
|
+
console.log("[ToolProtectionService] Using stale cache", {
|
|
210
|
+
cacheKey,
|
|
211
|
+
expiredAt: new Date(expiresAt).toISOString(),
|
|
212
|
+
staleAgeMs: now - expiresAt,
|
|
213
|
+
maxStaleAgeMs: maxStaleAge,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return staleConfig;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get tool protection configuration for the agent
|
|
224
|
+
*
|
|
225
|
+
* Flow:
|
|
226
|
+
* 1. Check cache (project-scoped if projectId available)
|
|
227
|
+
* 2. If cache miss, fetch from API
|
|
228
|
+
* 3. If API fails, use fallback config
|
|
229
|
+
* 4. Cache successful API responses
|
|
230
|
+
*
|
|
231
|
+
* @param agentDid DID of the agent to fetch config for
|
|
232
|
+
*/
|
|
233
|
+
async getToolProtectionConfig(
|
|
234
|
+
agentDid: string
|
|
235
|
+
): Promise<ToolProtectionConfig> {
|
|
236
|
+
// Use project-scoped cache key if projectId is available (preferred)
|
|
237
|
+
// Falls back to agent-scoped key for backward compatibility
|
|
238
|
+
// The cache implementation (KVToolProtectionCache) will add any necessary prefix
|
|
239
|
+
const cacheKey = this.config.projectId
|
|
240
|
+
? `config:tool-protections:${this.config.projectId}`
|
|
241
|
+
: `agent:${agentDid}`;
|
|
242
|
+
|
|
243
|
+
// 1. Check cache
|
|
244
|
+
const cached = await this.cache.get(cacheKey);
|
|
245
|
+
if (cached) {
|
|
246
|
+
const ttl = this.config.cacheTtl ?? 300000;
|
|
247
|
+
const cachedUntil = new Date(Date.now() + ttl).toISOString();
|
|
248
|
+
|
|
249
|
+
if (this.config.debug) {
|
|
250
|
+
console.log("[ToolProtectionService] Cache hit", {
|
|
251
|
+
source: "cache",
|
|
252
|
+
cacheKey,
|
|
253
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
254
|
+
projectId: this.config.projectId || "none",
|
|
255
|
+
toolCount: Object.keys(cached.toolProtections).length,
|
|
256
|
+
protectedTools: Object.entries(cached.toolProtections)
|
|
257
|
+
.filter(([_, config]: [string, ToolProtection]) => config.requiresDelegation)
|
|
258
|
+
.map(([name]) => name),
|
|
259
|
+
cacheTtlMs: ttl,
|
|
260
|
+
cachedUntil,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return cached;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (this.config.debug) {
|
|
267
|
+
console.log("[ToolProtectionService] Cache miss, fetching from API", {
|
|
268
|
+
source: "api-fetch-start",
|
|
269
|
+
cacheKey,
|
|
270
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
271
|
+
projectId: this.config.projectId || "none",
|
|
272
|
+
apiUrl: this.config.apiUrl,
|
|
273
|
+
endpoint: this.config.projectId
|
|
274
|
+
? `/api/v1/bouncer/projects/${this.config.projectId}/tool-protections`
|
|
275
|
+
: `/api/v1/bouncer/config?agent_did=${agentDid}`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 3. Fetch from API
|
|
280
|
+
try {
|
|
281
|
+
const response = await this.fetchFromApi(agentDid);
|
|
282
|
+
|
|
283
|
+
if (this.config.debug) {
|
|
284
|
+
console.log("[ToolProtectionService] API response received", {
|
|
285
|
+
source: "api-fetch-complete",
|
|
286
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
287
|
+
projectId: this.config.projectId || "none",
|
|
288
|
+
responseKeys: Object.keys(response),
|
|
289
|
+
dataKeys: response.data ? Object.keys(response.data) : [],
|
|
290
|
+
rawToolProtections: response.data?.toolProtections || null,
|
|
291
|
+
rawTools: response.data?.tools || null,
|
|
292
|
+
responseMetadata: response.metadata || null,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Transform API response format to internal format
|
|
297
|
+
// Supports multiple response formats:
|
|
298
|
+
// 1. New endpoint: { data: { toolProtections: { greet: { requiresDelegation: true, ... } } } }
|
|
299
|
+
// 2. Old endpoint (array): { data: { tools: [{ name: "greet", requiresDelegation: true, ... }] } }
|
|
300
|
+
// 3. Old endpoint (object): { data: { tools: { greet: { requiresDelegation: true, ... } } } }
|
|
301
|
+
const toolProtections: Record<string, ToolProtection> = {};
|
|
302
|
+
|
|
303
|
+
// Check for new endpoint format first (toolProtections)
|
|
304
|
+
if (response.data.toolProtections) {
|
|
305
|
+
// New endpoint format: object with tool names as keys
|
|
306
|
+
// Prefer camelCase over snake_case when both present
|
|
307
|
+
for (const [toolName, toolConfig] of Object.entries(
|
|
308
|
+
response.data.toolProtections
|
|
309
|
+
)) {
|
|
310
|
+
const requiresDelegation =
|
|
311
|
+
(toolConfig as any).requiresDelegation ??
|
|
312
|
+
(toolConfig as any).requires_delegation ??
|
|
313
|
+
false;
|
|
314
|
+
const requiredScopes =
|
|
315
|
+
(toolConfig as any).requiredScopes ??
|
|
316
|
+
(toolConfig as any).required_scopes ??
|
|
317
|
+
(toolConfig as any).scopes ??
|
|
318
|
+
[];
|
|
319
|
+
|
|
320
|
+
// NEW: Parse oauthProvider (camelCase and snake_case support)
|
|
321
|
+
const oauthProvider =
|
|
322
|
+
(toolConfig as any).oauthProvider ??
|
|
323
|
+
(toolConfig as any).oauth_provider ??
|
|
324
|
+
undefined;
|
|
325
|
+
|
|
326
|
+
const riskLevel =
|
|
327
|
+
(toolConfig as any).riskLevel ??
|
|
328
|
+
(toolConfig as any).risk_level ??
|
|
329
|
+
undefined;
|
|
330
|
+
|
|
331
|
+
toolProtections[toolName] = {
|
|
332
|
+
requiresDelegation,
|
|
333
|
+
requiredScopes,
|
|
334
|
+
...(oauthProvider && { oauthProvider }), // Only include if present
|
|
335
|
+
...(riskLevel && { riskLevel }), // Only include if present
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
} else if (response.data.tools) {
|
|
339
|
+
// Old endpoint format: array or object
|
|
340
|
+
if (Array.isArray(response.data.tools)) {
|
|
341
|
+
// Array format: [{ name: "greet", requiresDelegation: true, ... }]
|
|
342
|
+
for (const tool of response.data.tools) {
|
|
343
|
+
const toolName = tool.name;
|
|
344
|
+
if (!toolName) {
|
|
345
|
+
if (this.config.debug) {
|
|
346
|
+
console.warn(
|
|
347
|
+
"[ToolProtectionService] Tool missing name in array format",
|
|
348
|
+
tool
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Prefer camelCase over snake_case when both present
|
|
355
|
+
const requiresDelegation =
|
|
356
|
+
(tool as any).requiresDelegation ??
|
|
357
|
+
(tool as any).requires_delegation ??
|
|
358
|
+
false;
|
|
359
|
+
const requiredScopes =
|
|
360
|
+
(tool as any).requiredScopes ??
|
|
361
|
+
(tool as any).required_scopes ??
|
|
362
|
+
(tool as any).scopes ??
|
|
363
|
+
[];
|
|
364
|
+
|
|
365
|
+
// NEW: Parse oauthProvider
|
|
366
|
+
const oauthProvider =
|
|
367
|
+
(tool as any).oauthProvider ??
|
|
368
|
+
(tool as any).oauth_provider ??
|
|
369
|
+
undefined;
|
|
370
|
+
|
|
371
|
+
const riskLevel =
|
|
372
|
+
(tool as any).riskLevel ??
|
|
373
|
+
(tool as any).risk_level ??
|
|
374
|
+
undefined;
|
|
375
|
+
|
|
376
|
+
toolProtections[toolName] = {
|
|
377
|
+
requiresDelegation,
|
|
378
|
+
requiredScopes,
|
|
379
|
+
...(oauthProvider && { oauthProvider }),
|
|
380
|
+
...(riskLevel && { riskLevel }),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
// Object format: { greet: { requiresDelegation: true, ... } }
|
|
385
|
+
for (const [toolName, toolConfig] of Object.entries(
|
|
386
|
+
response.data.tools
|
|
387
|
+
)) {
|
|
388
|
+
// Prefer camelCase over snake_case when both present
|
|
389
|
+
const requiresDelegation =
|
|
390
|
+
(toolConfig as any).requiresDelegation ??
|
|
391
|
+
(toolConfig as any).requires_delegation ??
|
|
392
|
+
false;
|
|
393
|
+
const requiredScopes =
|
|
394
|
+
(toolConfig as any).requiredScopes ??
|
|
395
|
+
(toolConfig as any).required_scopes ??
|
|
396
|
+
(toolConfig as any).scopes ??
|
|
397
|
+
[];
|
|
398
|
+
|
|
399
|
+
// NEW: Parse oauthProvider
|
|
400
|
+
const oauthProvider =
|
|
401
|
+
(toolConfig as any).oauthProvider ??
|
|
402
|
+
(toolConfig as any).oauth_provider ??
|
|
403
|
+
undefined;
|
|
404
|
+
|
|
405
|
+
const riskLevel =
|
|
406
|
+
(toolConfig as any).riskLevel ??
|
|
407
|
+
(toolConfig as any).risk_level ??
|
|
408
|
+
undefined;
|
|
409
|
+
|
|
410
|
+
toolProtections[toolName] = {
|
|
411
|
+
requiresDelegation,
|
|
412
|
+
requiredScopes,
|
|
413
|
+
...(oauthProvider && { oauthProvider }),
|
|
414
|
+
...(riskLevel && { riskLevel }),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Merge with fallback config (local config takes priority over API)
|
|
421
|
+
// This allows users to override API responses with local configuration
|
|
422
|
+
// Note: Fallback config is also used when API fails, but here we merge even when API succeeds
|
|
423
|
+
const mergedToolProtections = { ...toolProtections };
|
|
424
|
+
if (this.config.fallbackConfig?.toolProtections) {
|
|
425
|
+
for (const [toolName, localConfig] of Object.entries(
|
|
426
|
+
this.config.fallbackConfig.toolProtections
|
|
427
|
+
)) {
|
|
428
|
+
// Skip if localConfig is empty or not a valid ToolProtection object
|
|
429
|
+
// This prevents empty objects from corrupting the merged config
|
|
430
|
+
if (!localConfig || typeof localConfig !== 'object' || Object.keys(localConfig).length === 0) {
|
|
431
|
+
if (this.config.debug) {
|
|
432
|
+
console.log(
|
|
433
|
+
"[ToolProtectionService] Skipping empty/invalid fallback config entry",
|
|
434
|
+
{ tool: toolName }
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Ensure requiredScopes exists (default to empty array if missing)
|
|
441
|
+
const validConfig: ToolProtection = {
|
|
442
|
+
requiresDelegation: (localConfig as any).requiresDelegation ?? false,
|
|
443
|
+
requiredScopes: (localConfig as any).requiredScopes ?? [],
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Local config overrides API config for this tool
|
|
447
|
+
mergedToolProtections[toolName] = validConfig;
|
|
448
|
+
if (this.config.debug) {
|
|
449
|
+
console.log(
|
|
450
|
+
"[ToolProtectionService] Overriding API config with local config",
|
|
451
|
+
{
|
|
452
|
+
tool: toolName,
|
|
453
|
+
localConfig: validConfig,
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const config: ToolProtectionConfig = {
|
|
461
|
+
toolProtections: mergedToolProtections,
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// 3. Cache the response
|
|
465
|
+
const ttl = this.config.cacheTtl ?? 300000; // Default 5 minutes
|
|
466
|
+
const cacheExpiration = new Date(Date.now() + ttl);
|
|
467
|
+
|
|
468
|
+
await this.cache.set(cacheKey, config, ttl);
|
|
469
|
+
|
|
470
|
+
// Always log tool count and protection status (critical for debugging)
|
|
471
|
+
console.log("[ToolProtectionService] Config loaded from API", {
|
|
472
|
+
source: "api",
|
|
473
|
+
toolCount: Object.keys(mergedToolProtections).length,
|
|
474
|
+
protectedTools: Object.entries(mergedToolProtections)
|
|
475
|
+
.filter(([_, config]: [string, ToolProtection]) => config.requiresDelegation)
|
|
476
|
+
.map(([name]) => name),
|
|
477
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
478
|
+
projectId: this.config.projectId || "none",
|
|
479
|
+
cacheTtlMs: ttl,
|
|
480
|
+
cacheExpiresAt: cacheExpiration.toISOString(),
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
if (this.config.debug) {
|
|
484
|
+
console.log(
|
|
485
|
+
"[ToolProtectionService] API fetch successful, config cached",
|
|
486
|
+
{
|
|
487
|
+
source: "cache-write",
|
|
488
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
489
|
+
cacheKey,
|
|
490
|
+
toolCount: Object.keys(mergedToolProtections).length,
|
|
491
|
+
tools: Object.entries(mergedToolProtections).map(
|
|
492
|
+
([name, config]) => ({
|
|
493
|
+
name,
|
|
494
|
+
requiresDelegation: config.requiresDelegation,
|
|
495
|
+
scopeCount: (config.requiredScopes || []).length, // Safe access
|
|
496
|
+
})
|
|
497
|
+
),
|
|
498
|
+
ttlMs: ttl,
|
|
499
|
+
ttlMinutes: Math.round(ttl / 60000),
|
|
500
|
+
expiresAt: cacheExpiration.toISOString(),
|
|
501
|
+
expiresIn: `${Math.round(ttl / 1000)}s`,
|
|
502
|
+
}
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return config;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
const errorMessage =
|
|
509
|
+
error instanceof Error ? error.message : String(error);
|
|
510
|
+
|
|
511
|
+
// Re-throw API key validation errors (don't fallback)
|
|
512
|
+
if (errorMessage.includes("API key is missing or empty")) {
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Re-throw HTTP errors (4xx, 5xx) - these indicate API issues, not network failures
|
|
517
|
+
// Exception: 429 (rate limit) should fallback if fallback config is available
|
|
518
|
+
if (errorMessage.includes("Failed to fetch bouncer config:")) {
|
|
519
|
+
const status = (error as any).status;
|
|
520
|
+
// Allow 429 to fallback (rate limiting is temporary, fallback is acceptable)
|
|
521
|
+
if (status === 429 && this.config.fallbackConfig) {
|
|
522
|
+
// Will fall through to fallback logic below
|
|
523
|
+
} else {
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Re-throw JSON parsing errors
|
|
529
|
+
if (errorMessage.includes("Failed to parse API response:")) {
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Re-throw API success: false errors
|
|
534
|
+
if (errorMessage.includes("API returned success: false")) {
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (this.config.debug) {
|
|
539
|
+
console.error("[ToolProtectionService] API fetch failed", {
|
|
540
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
541
|
+
error: errorMessage,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 4. Fallback to stale cache if available (before fallback config)
|
|
546
|
+
const staleCache = await this.getStaleCache(cacheKey);
|
|
547
|
+
if (staleCache) {
|
|
548
|
+
console.warn(
|
|
549
|
+
"[ToolProtectionService] API fetch failed, using stale cache",
|
|
550
|
+
{
|
|
551
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
552
|
+
error: errorMessage,
|
|
553
|
+
cacheKey,
|
|
554
|
+
}
|
|
555
|
+
);
|
|
556
|
+
return staleCache;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// 5. Fallback to local config (only for network errors, not API errors)
|
|
560
|
+
if (this.config.fallbackConfig) {
|
|
561
|
+
// Always warn when using fallback (not just debug mode)
|
|
562
|
+
console.warn(
|
|
563
|
+
"[ToolProtectionService] API fetch failed, using fallback config",
|
|
564
|
+
{
|
|
565
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
566
|
+
error: errorMessage,
|
|
567
|
+
}
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
// Cache the fallback config to avoid repeated API calls
|
|
571
|
+
const ttl = this.config.cacheTtl ?? 300000; // Default 5 minutes
|
|
572
|
+
await this.cache.set(cacheKey, this.config.fallbackConfig, ttl);
|
|
573
|
+
|
|
574
|
+
return this.config.fallbackConfig;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 6. Fail-safe behavior: deny-all by default (secure)
|
|
578
|
+
const failSafeBehavior = this.config.failSafeBehavior ?? "deny-all";
|
|
579
|
+
|
|
580
|
+
if (failSafeBehavior === "deny-all") {
|
|
581
|
+
console.error(
|
|
582
|
+
"[ToolProtectionService] API fetch failed, no fallback, failing closed (deny-all)",
|
|
583
|
+
{
|
|
584
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
585
|
+
error: errorMessage,
|
|
586
|
+
cacheKey,
|
|
587
|
+
}
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
// Create deny-all config (wildcard requires delegation for all tools)
|
|
591
|
+
const denyAllConfig: ToolProtectionConfig = {
|
|
592
|
+
toolProtections: {
|
|
593
|
+
"*": {
|
|
594
|
+
requiresDelegation: true,
|
|
595
|
+
requiredScopes: [],
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// Cache deny-all config to avoid repeated API calls
|
|
601
|
+
const ttl = this.config.cacheTtl ?? 300000; // Default 5 minutes
|
|
602
|
+
await this.cache.set(cacheKey, denyAllConfig, ttl);
|
|
603
|
+
|
|
604
|
+
return denyAllConfig;
|
|
605
|
+
} else {
|
|
606
|
+
// failSafeBehavior === 'allow-all' (insecure, not recommended)
|
|
607
|
+
console.warn(
|
|
608
|
+
"[ToolProtectionService] API fetch failed, no fallback, failing open (allow-all)",
|
|
609
|
+
{
|
|
610
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
611
|
+
error: errorMessage,
|
|
612
|
+
cacheKey,
|
|
613
|
+
}
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
// Cache empty config to avoid repeated API calls
|
|
617
|
+
const emptyConfig: ToolProtectionConfig = { toolProtections: {} };
|
|
618
|
+
const ttl = this.config.cacheTtl ?? 300000; // Default 5 minutes
|
|
619
|
+
await this.cache.set(cacheKey, emptyConfig, ttl);
|
|
620
|
+
|
|
621
|
+
return emptyConfig;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Check if a tool requires delegation
|
|
628
|
+
*
|
|
629
|
+
* @param toolName Name of the tool to check
|
|
630
|
+
* @param agentDid DID of the agent executing the tool
|
|
631
|
+
* @returns Tool protection config or null if no protection required
|
|
632
|
+
*/
|
|
633
|
+
async checkToolProtection(
|
|
634
|
+
toolName: string,
|
|
635
|
+
agentDid: string
|
|
636
|
+
): Promise<ToolProtection | null> {
|
|
637
|
+
const config = await this.getToolProtectionConfig(agentDid);
|
|
638
|
+
|
|
639
|
+
// Check for specific tool protection first
|
|
640
|
+
let protection = config.toolProtections[toolName];
|
|
641
|
+
|
|
642
|
+
// If not found, check for wildcard protection (fail-safe deny-all)
|
|
643
|
+
if (!protection && config.toolProtections["*"]) {
|
|
644
|
+
protection = config.toolProtections["*"];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Always log the check result (critical for debugging)
|
|
648
|
+
if (this.config.debug || !protection || protection.requiresDelegation) {
|
|
649
|
+
console.log("[ToolProtectionService] Protection check", {
|
|
650
|
+
tool: toolName,
|
|
651
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
652
|
+
found: !!protection,
|
|
653
|
+
isWildcard: protection === config.toolProtections["*"],
|
|
654
|
+
requiresDelegation: protection?.requiresDelegation ?? false,
|
|
655
|
+
availableTools: Object.keys(config.toolProtections),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!protection || !protection.requiresDelegation) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return protection;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Fetch tool protection config from AgentShield API
|
|
668
|
+
* Uses projectId endpoint if available (preferred, project-scoped), otherwise falls back to agent_did query param
|
|
669
|
+
*
|
|
670
|
+
* @param agentDid DID of the agent to fetch config for
|
|
671
|
+
* @param options Optional fetch options
|
|
672
|
+
* @param options.bypassCDNCache When true, adds cache-busting to bypass CDN caches (used by clearAndRefresh)
|
|
673
|
+
*/
|
|
674
|
+
private async fetchFromApi(
|
|
675
|
+
agentDid: string,
|
|
676
|
+
options?: { bypassCDNCache?: boolean }
|
|
677
|
+
): Promise<BouncerConfigApiResponse> {
|
|
678
|
+
// Prefer new project-scoped endpoint: /api/v1/bouncer/projects/{projectId}/tool-protections
|
|
679
|
+
// Falls back to old endpoint: /api/v1/bouncer/config?agent_did={did} for backward compatibility
|
|
680
|
+
let url: string;
|
|
681
|
+
let useNewEndpoint = false;
|
|
682
|
+
|
|
683
|
+
if (this.config.projectId) {
|
|
684
|
+
// ✅ NEW ENDPOINT: Project-scoped, returns toolProtections object
|
|
685
|
+
url = `${this.config.apiUrl}/api/v1/bouncer/projects/${encodeURIComponent(this.config.projectId)}/tool-protections`;
|
|
686
|
+
useNewEndpoint = true;
|
|
687
|
+
} else {
|
|
688
|
+
// ⚠️ OLD ENDPOINT: Agent-scoped, returns tools array (backward compatibility)
|
|
689
|
+
url = `${this.config.apiUrl}/api/v1/bouncer/config?agent_did=${encodeURIComponent(agentDid)}`;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Add cache-busting query param when bypassing CDN cache
|
|
693
|
+
// This is used during cache invalidation (clearAndRefresh) to ensure we get fresh data
|
|
694
|
+
// from the origin server, not stale CDN-cached data
|
|
695
|
+
if (options?.bypassCDNCache) {
|
|
696
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
697
|
+
url = `${url}${separator}_t=${Date.now()}`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Debug: Log API key status (masked for security)
|
|
701
|
+
// Format: sk_live... (prefix up to first underscore after type, then ...)
|
|
702
|
+
const apiKeyMasked = this.config.apiKey
|
|
703
|
+
? (() => {
|
|
704
|
+
const parts = this.config.apiKey.split("_");
|
|
705
|
+
if (parts.length >= 2) {
|
|
706
|
+
return `${parts[0]}_${parts[1]}...`;
|
|
707
|
+
}
|
|
708
|
+
return `${this.config.apiKey.substring(0, 8)}...`;
|
|
709
|
+
})()
|
|
710
|
+
: "(empty)";
|
|
711
|
+
const apiKeyLength = this.config.apiKey?.length || 0;
|
|
712
|
+
|
|
713
|
+
if (this.config.debug) {
|
|
714
|
+
console.log("[ToolProtectionService] Fetching from API:", url, {
|
|
715
|
+
method: useNewEndpoint
|
|
716
|
+
? "projects/{projectId}/tool-protections (new)"
|
|
717
|
+
: "config?agent_did (old)",
|
|
718
|
+
projectId: this.config.projectId || "none",
|
|
719
|
+
apiKeyPresent: !!this.config.apiKey,
|
|
720
|
+
apiKeyLength,
|
|
721
|
+
apiKeyMasked,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Validate API key is present
|
|
726
|
+
if (!this.config.apiKey || this.config.apiKey.trim() === "") {
|
|
727
|
+
const error = "API key is missing or empty";
|
|
728
|
+
if (this.config.debug) {
|
|
729
|
+
console.error("[ToolProtectionService]", error, {
|
|
730
|
+
apiKeyPresent: !!this.config.apiKey,
|
|
731
|
+
apiKeyLength,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
throw new Error(
|
|
735
|
+
`ToolProtectionService: ${error}. Check AGENTSHIELD_API_KEY in .dev.vars or wrangler.toml [vars]`
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Build headers - new endpoint uses X-API-Key, old endpoint uses Authorization Bearer
|
|
740
|
+
const headers: Record<string, string> = {
|
|
741
|
+
"Content-Type": "application/json",
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
if (useNewEndpoint) {
|
|
745
|
+
// ✅ New endpoint headers
|
|
746
|
+
headers["X-API-Key"] = this.config.apiKey;
|
|
747
|
+
if (this.config.projectId) {
|
|
748
|
+
headers["X-Project-Id"] = this.config.projectId;
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
// ⚠️ Old endpoint headers (backward compatibility)
|
|
752
|
+
headers["Authorization"] = `Bearer ${this.config.apiKey}`;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Add cache-control header when bypassing CDN cache
|
|
756
|
+
// This tells any intermediate caches (CDN, proxies) not to serve cached content
|
|
757
|
+
if (options?.bypassCDNCache) {
|
|
758
|
+
headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
|
|
759
|
+
headers["Pragma"] = "no-cache"; // HTTP/1.0 backward compatibility
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const response = await fetch(url, {
|
|
763
|
+
method: "GET",
|
|
764
|
+
headers,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
if (!response.ok) {
|
|
768
|
+
const errorText = await response.text().catch(() => "Unknown error");
|
|
769
|
+
const error = new Error(
|
|
770
|
+
`Failed to fetch bouncer config: ${response.status} ${response.statusText} - ${errorText}`
|
|
771
|
+
);
|
|
772
|
+
(error as any).status = response.status;
|
|
773
|
+
throw error;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
let data: BouncerConfigApiResponse;
|
|
777
|
+
try {
|
|
778
|
+
data = (await response.json()) as BouncerConfigApiResponse;
|
|
779
|
+
} catch (jsonError) {
|
|
780
|
+
throw new Error(
|
|
781
|
+
`Failed to parse API response: ${jsonError instanceof Error ? jsonError.message : String(jsonError)}`
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (!data.success) {
|
|
786
|
+
throw new Error("API returned success: false");
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return data;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Clear the cache for a project or agent (useful for testing or manual refresh)
|
|
794
|
+
*
|
|
795
|
+
* If projectId is configured, clears project-scoped cache.
|
|
796
|
+
* Otherwise, clears agent-scoped cache.
|
|
797
|
+
*
|
|
798
|
+
* @param agentDid DID of the agent (used for fallback if projectId not available)
|
|
799
|
+
*/
|
|
800
|
+
async clearCache(agentDid: string): Promise<void> {
|
|
801
|
+
// Use same cache key strategy as getToolProtectionConfig
|
|
802
|
+
const cacheKey = this.config.projectId
|
|
803
|
+
? `config:tool-protections:${this.config.projectId}`
|
|
804
|
+
: `agent:${agentDid}`;
|
|
805
|
+
|
|
806
|
+
if (this.config.debug) {
|
|
807
|
+
console.log("[ToolProtectionService] Clearing cache", {
|
|
808
|
+
cacheKey,
|
|
809
|
+
projectId: this.config.projectId || "none",
|
|
810
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
await this.cache.delete(cacheKey);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Clear cache and immediately fetch fresh config from API
|
|
819
|
+
*
|
|
820
|
+
* This method is designed for Cloudflare Workers where KV has edge caching.
|
|
821
|
+
* After clearing the KV entry, it fetches fresh data from the API and writes
|
|
822
|
+
* it back to KV. This ensures:
|
|
823
|
+
* 1. The global KV entry is deleted
|
|
824
|
+
* 2. Fresh data is fetched from API (with CDN cache bypass!)
|
|
825
|
+
* 3. New data is written to KV (updating edge cache)
|
|
826
|
+
*
|
|
827
|
+
* The next request from the same edge location will get the fresh data.
|
|
828
|
+
*
|
|
829
|
+
* IMPORTANT: This method uses bypassCDNCache to ensure we get fresh data
|
|
830
|
+
* from AgentShield's origin server, not stale CDN-cached data. This is
|
|
831
|
+
* critical for instant cache invalidation when tool protection settings
|
|
832
|
+
* are changed in the AgentShield dashboard.
|
|
833
|
+
*
|
|
834
|
+
* @param agentDid DID of the agent (used for cache key)
|
|
835
|
+
* @returns The fresh tool protection config from API
|
|
836
|
+
*/
|
|
837
|
+
async clearAndRefresh(agentDid: string): Promise<{
|
|
838
|
+
config: ToolProtectionConfig;
|
|
839
|
+
cacheKey: string;
|
|
840
|
+
source: 'api' | 'fallback';
|
|
841
|
+
}> {
|
|
842
|
+
const cacheKey = this.config.projectId
|
|
843
|
+
? `config:tool-protections:${this.config.projectId}`
|
|
844
|
+
: `agent:${agentDid}`;
|
|
845
|
+
|
|
846
|
+
console.log("[ToolProtectionService] clearAndRefresh starting", {
|
|
847
|
+
cacheKey,
|
|
848
|
+
projectId: this.config.projectId || "none",
|
|
849
|
+
agentDid: agentDid.slice(0, 20) + "...",
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// 1. Delete the cache entry
|
|
853
|
+
await this.cache.delete(cacheKey);
|
|
854
|
+
|
|
855
|
+
console.log("[ToolProtectionService] Cache entry deleted", { cacheKey });
|
|
856
|
+
|
|
857
|
+
// 2. Fetch fresh config from API with CDN cache bypass
|
|
858
|
+
// This ensures we get fresh data from origin, not stale CDN data
|
|
859
|
+
try {
|
|
860
|
+
const response = await this.fetchFromApi(agentDid, { bypassCDNCache: true });
|
|
861
|
+
|
|
862
|
+
// Transform API response to internal format (same logic as getToolProtectionConfig)
|
|
863
|
+
const toolProtections: Record<string, ToolProtection> = {};
|
|
864
|
+
|
|
865
|
+
if (response.data.toolProtections) {
|
|
866
|
+
for (const [toolName, toolConfig] of Object.entries(
|
|
867
|
+
response.data.toolProtections
|
|
868
|
+
)) {
|
|
869
|
+
const requiresDelegation =
|
|
870
|
+
(toolConfig as any).requiresDelegation ??
|
|
871
|
+
(toolConfig as any).requires_delegation ??
|
|
872
|
+
false;
|
|
873
|
+
const requiredScopes =
|
|
874
|
+
(toolConfig as any).requiredScopes ??
|
|
875
|
+
(toolConfig as any).required_scopes ??
|
|
876
|
+
(toolConfig as any).scopes ??
|
|
877
|
+
[];
|
|
878
|
+
const oauthProvider =
|
|
879
|
+
(toolConfig as any).oauthProvider ??
|
|
880
|
+
(toolConfig as any).oauth_provider ??
|
|
881
|
+
undefined;
|
|
882
|
+
const riskLevel =
|
|
883
|
+
(toolConfig as any).riskLevel ??
|
|
884
|
+
(toolConfig as any).risk_level ??
|
|
885
|
+
undefined;
|
|
886
|
+
|
|
887
|
+
toolProtections[toolName] = {
|
|
888
|
+
requiresDelegation,
|
|
889
|
+
requiredScopes,
|
|
890
|
+
...(oauthProvider && { oauthProvider }),
|
|
891
|
+
...(riskLevel && { riskLevel }),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
} else if (response.data.tools) {
|
|
895
|
+
if (Array.isArray(response.data.tools)) {
|
|
896
|
+
for (const tool of response.data.tools) {
|
|
897
|
+
const toolName = (tool as any).name;
|
|
898
|
+
if (!toolName) continue;
|
|
899
|
+
const requiresDelegation =
|
|
900
|
+
(tool as any).requiresDelegation ?? (tool as any).requires_delegation ?? false;
|
|
901
|
+
const requiredScopes =
|
|
902
|
+
(tool as any).requiredScopes ??
|
|
903
|
+
(tool as any).required_scopes ??
|
|
904
|
+
(tool as any).scopes ??
|
|
905
|
+
[];
|
|
906
|
+
const oauthProvider =
|
|
907
|
+
(tool as any).oauthProvider ?? (tool as any).oauth_provider ?? undefined;
|
|
908
|
+
const riskLevel = (tool as any).riskLevel ?? (tool as any).risk_level ?? undefined;
|
|
909
|
+
|
|
910
|
+
toolProtections[toolName] = {
|
|
911
|
+
requiresDelegation,
|
|
912
|
+
requiredScopes,
|
|
913
|
+
...(oauthProvider && { oauthProvider }),
|
|
914
|
+
...(riskLevel && { riskLevel }),
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
} else {
|
|
918
|
+
for (const [toolName, toolConfig] of Object.entries(
|
|
919
|
+
response.data.tools
|
|
920
|
+
)) {
|
|
921
|
+
const requiresDelegation =
|
|
922
|
+
(toolConfig as any).requiresDelegation ??
|
|
923
|
+
(toolConfig as any).requires_delegation ??
|
|
924
|
+
false;
|
|
925
|
+
const requiredScopes =
|
|
926
|
+
(toolConfig as any).requiredScopes ??
|
|
927
|
+
(toolConfig as any).required_scopes ??
|
|
928
|
+
(toolConfig as any).scopes ??
|
|
929
|
+
[];
|
|
930
|
+
const oauthProvider =
|
|
931
|
+
(toolConfig as any).oauthProvider ??
|
|
932
|
+
(toolConfig as any).oauth_provider ??
|
|
933
|
+
undefined;
|
|
934
|
+
const riskLevel =
|
|
935
|
+
(toolConfig as any).riskLevel ??
|
|
936
|
+
(toolConfig as any).risk_level ??
|
|
937
|
+
undefined;
|
|
938
|
+
|
|
939
|
+
toolProtections[toolName] = {
|
|
940
|
+
requiresDelegation,
|
|
941
|
+
requiredScopes,
|
|
942
|
+
...(oauthProvider && { oauthProvider }),
|
|
943
|
+
...(riskLevel && { riskLevel }),
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Construct fresh config (ToolProtectionConfig type)
|
|
950
|
+
const freshConfig: ToolProtectionConfig = {
|
|
951
|
+
toolProtections,
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
// 3. Write fresh config to cache
|
|
955
|
+
const ttl = this.config.cacheTtl ?? 300000;
|
|
956
|
+
await this.cache.set(cacheKey, freshConfig, ttl);
|
|
957
|
+
|
|
958
|
+
console.log("[ToolProtectionService] Fresh config fetched and cached", {
|
|
959
|
+
cacheKey,
|
|
960
|
+
toolCount: Object.keys(toolProtections).length,
|
|
961
|
+
protectedTools: Object.entries(toolProtections)
|
|
962
|
+
.filter(([_, cfg]) => cfg.requiresDelegation)
|
|
963
|
+
.map(([name]) => name),
|
|
964
|
+
source: "api",
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
return { config: freshConfig, cacheKey, source: 'api' };
|
|
968
|
+
} catch (error) {
|
|
969
|
+
console.warn("[ToolProtectionService] API fetch failed during refresh, using fallback", {
|
|
970
|
+
error: error instanceof Error ? error.message : String(error),
|
|
971
|
+
cacheKey,
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
// Use fallback config if API fails
|
|
975
|
+
const fallbackConfig: ToolProtectionConfig = this.config.fallbackConfig || {
|
|
976
|
+
toolProtections: {},
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
return { config: fallbackConfig, cacheKey, source: 'fallback' };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|