index.ts
1 /** 2 * Policy Limits Service 3 * 4 * Fetches organization-level policy restrictions from the API and uses them 5 * to disable CLI features. Follows the same patterns as remote managed settings 6 * (fail open, ETag caching, background polling, retry logic). 7 * 8 * Eligibility: 9 * - Console users (API key): All eligible 10 * - OAuth users (Claude.ai): Only Team and Enterprise/C4E subscribers are eligible 11 * - API fails open (non-blocking) - if fetch fails, continues without restrictions 12 * - API returns empty restrictions for users without policy limits 13 */ 14 15 import axios from 'axios' 16 import { createHash } from 'crypto' 17 import { readFileSync as fsReadFileSync } from 'fs' 18 import { unlink, writeFile } from 'fs/promises' 19 import { join } from 'path' 20 import { 21 CLAUDE_AI_INFERENCE_SCOPE, 22 getOauthConfig, 23 OAUTH_BETA_HEADER, 24 } from '../../constants/oauth.js' 25 import { 26 checkAndRefreshOAuthTokenIfNeeded, 27 getAnthropicApiKeyWithSource, 28 getClaudeAIOAuthTokens, 29 } from '../../utils/auth.js' 30 import { registerCleanup } from '../../utils/cleanupRegistry.js' 31 import { logForDebugging } from '../../utils/debug.js' 32 import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 33 import { classifyAxiosError } from '../../utils/errors.js' 34 import { safeParseJSON } from '../../utils/json.js' 35 import { 36 getAPIProvider, 37 isFirstPartyAnthropicBaseUrl, 38 } from '../../utils/model/providers.js' 39 import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' 40 import { sleep } from '../../utils/sleep.js' 41 import { jsonStringify } from '../../utils/slowOperations.js' 42 import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 43 import { getRetryDelay } from '../api/withRetry.js' 44 import { 45 type PolicyLimitsFetchResult, 46 type PolicyLimitsResponse, 47 PolicyLimitsResponseSchema, 48 } from './types.js' 49 50 function isNodeError(e: unknown): e is NodeJS.ErrnoException { 51 return e instanceof Error 52 } 53 54 // Constants 55 const CACHE_FILENAME = 'policy-limits.json' 56 const FETCH_TIMEOUT_MS = 10000 // 10 seconds 57 const DEFAULT_MAX_RETRIES = 5 58 const POLLING_INTERVAL_MS = 60 * 60 * 1000 // 1 hour 59 60 // Background polling state 61 let pollingIntervalId: ReturnType<typeof setInterval> | null = null 62 let cleanupRegistered = false 63 64 // Promise that resolves when initial policy limits loading completes 65 let loadingCompletePromise: Promise<void> | null = null 66 let loadingCompleteResolve: (() => void) | null = null 67 68 // Timeout for the loading promise to prevent deadlocks 69 const LOADING_PROMISE_TIMEOUT_MS = 30000 // 30 seconds 70 71 // Session-level cache for policy restrictions 72 let sessionCache: PolicyLimitsResponse['restrictions'] | null = null 73 74 /** 75 * Test-only sync reset. clearPolicyLimitsCache() does file I/O and is too 76 * expensive for preload beforeEach; this only clears the module-level 77 * singleton so downstream tests in the same shard see a clean slate. 78 */ 79 export function _resetPolicyLimitsForTesting(): void { 80 stopBackgroundPolling() 81 sessionCache = null 82 loadingCompletePromise = null 83 loadingCompleteResolve = null 84 } 85 86 /** 87 * Initialize the loading promise for policy limits 88 * This should be called early (e.g., in init.ts) to allow other systems 89 * to await policy limits loading even if loadPolicyLimits() hasn't been called yet. 90 * 91 * Only creates the promise if the user is eligible for policy limits. 92 * Includes a timeout to prevent deadlocks if loadPolicyLimits() is never called. 93 */ 94 export function initializePolicyLimitsLoadingPromise(): void { 95 if (loadingCompletePromise) { 96 return 97 } 98 99 if (isPolicyLimitsEligible()) { 100 loadingCompletePromise = new Promise(resolve => { 101 loadingCompleteResolve = resolve 102 103 setTimeout(() => { 104 if (loadingCompleteResolve) { 105 logForDebugging( 106 'Policy limits: Loading promise timed out, resolving anyway', 107 ) 108 loadingCompleteResolve() 109 loadingCompleteResolve = null 110 } 111 }, LOADING_PROMISE_TIMEOUT_MS) 112 }) 113 } 114 } 115 116 /** 117 * Get the path to the policy limits cache file 118 */ 119 function getCachePath(): string { 120 return join(getClaudeConfigHomeDir(), CACHE_FILENAME) 121 } 122 123 /** 124 * Get the policy limits API endpoint 125 */ 126 function getPolicyLimitsEndpoint(): string { 127 return `${getOauthConfig().BASE_API_URL}/api/claude_code/policy_limits` 128 } 129 130 /** 131 * Recursively sort all keys in an object for consistent hashing 132 */ 133 function sortKeysDeep(obj: unknown): unknown { 134 if (Array.isArray(obj)) { 135 return obj.map(sortKeysDeep) 136 } 137 if (obj !== null && typeof obj === 'object') { 138 const sorted: Record<string, unknown> = {} 139 for (const [key, value] of Object.entries(obj).sort(([a], [b]) => 140 a.localeCompare(b), 141 )) { 142 sorted[key] = sortKeysDeep(value) 143 } 144 return sorted 145 } 146 return obj 147 } 148 149 /** 150 * Compute a checksum from restrictions content for HTTP caching 151 */ 152 function computeChecksum( 153 restrictions: PolicyLimitsResponse['restrictions'], 154 ): string { 155 const sorted = sortKeysDeep(restrictions) 156 const normalized = jsonStringify(sorted) 157 const hash = createHash('sha256').update(normalized).digest('hex') 158 return `sha256:${hash}` 159 } 160 161 /** 162 * Check if the current user is eligible for policy limits. 163 * 164 * IMPORTANT: This function must NOT call getSettings() or any function that calls 165 * getSettings() to avoid circular dependencies during settings loading. 166 */ 167 export function isPolicyLimitsEligible(): boolean { 168 // 3p provider users should not hit the policy limits endpoint 169 if (getAPIProvider() !== 'firstParty') { 170 return false 171 } 172 173 // Custom base URL users should not hit the policy limits endpoint 174 if (!isFirstPartyAnthropicBaseUrl()) { 175 return false 176 } 177 178 // Console users (API key) are eligible if we can get the actual key 179 try { 180 const { key: apiKey } = getAnthropicApiKeyWithSource({ 181 skipRetrievingKeyFromApiKeyHelper: true, 182 }) 183 if (apiKey) { 184 return true 185 } 186 } catch { 187 // No API key available - continue to check OAuth 188 } 189 190 // For OAuth users, check if they have Claude.ai tokens 191 const tokens = getClaudeAIOAuthTokens() 192 if (!tokens?.accessToken) { 193 return false 194 } 195 196 // Must have Claude.ai inference scope 197 if (!tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE)) { 198 return false 199 } 200 201 // Only Team and Enterprise OAuth users are eligible — these orgs have 202 // admin-configurable policy restrictions (e.g. allow_remote_sessions) 203 if ( 204 tokens.subscriptionType !== 'enterprise' && 205 tokens.subscriptionType !== 'team' 206 ) { 207 return false 208 } 209 210 return true 211 } 212 213 /** 214 * Wait for the initial policy limits loading to complete 215 * Returns immediately if user is not eligible or loading has already completed 216 */ 217 export async function waitForPolicyLimitsToLoad(): Promise<void> { 218 if (loadingCompletePromise) { 219 await loadingCompletePromise 220 } 221 } 222 223 /** 224 * Get auth headers for policy limits without calling getSettings() 225 * Supports both API key and OAuth authentication 226 */ 227 function getAuthHeaders(): { 228 headers: Record<string, string> 229 error?: string 230 } { 231 // Try API key first (for Console users) 232 try { 233 const { key: apiKey } = getAnthropicApiKeyWithSource({ 234 skipRetrievingKeyFromApiKeyHelper: true, 235 }) 236 if (apiKey) { 237 return { 238 headers: { 239 'x-api-key': apiKey, 240 }, 241 } 242 } 243 } catch { 244 // No API key available - continue to check OAuth 245 } 246 247 // Fall back to OAuth tokens (for Claude.ai users) 248 const oauthTokens = getClaudeAIOAuthTokens() 249 if (oauthTokens?.accessToken) { 250 return { 251 headers: { 252 Authorization: `Bearer ${oauthTokens.accessToken}`, 253 'anthropic-beta': OAUTH_BETA_HEADER, 254 }, 255 } 256 } 257 258 return { 259 headers: {}, 260 error: 'No authentication available', 261 } 262 } 263 264 /** 265 * Fetch policy limits with retry logic and exponential backoff 266 */ 267 async function fetchWithRetry( 268 cachedChecksum?: string, 269 ): Promise<PolicyLimitsFetchResult> { 270 let lastResult: PolicyLimitsFetchResult | null = null 271 272 for (let attempt = 1; attempt <= DEFAULT_MAX_RETRIES + 1; attempt++) { 273 lastResult = await fetchPolicyLimits(cachedChecksum) 274 275 if (lastResult.success) { 276 return lastResult 277 } 278 279 if (lastResult.skipRetry) { 280 return lastResult 281 } 282 283 if (attempt > DEFAULT_MAX_RETRIES) { 284 return lastResult 285 } 286 287 const delayMs = getRetryDelay(attempt) 288 logForDebugging( 289 `Policy limits: Retry ${attempt}/${DEFAULT_MAX_RETRIES} after ${delayMs}ms`, 290 ) 291 await sleep(delayMs) 292 } 293 294 return lastResult! 295 } 296 297 /** 298 * Fetch policy limits (single attempt, no retries) 299 */ 300 async function fetchPolicyLimits( 301 cachedChecksum?: string, 302 ): Promise<PolicyLimitsFetchResult> { 303 try { 304 await checkAndRefreshOAuthTokenIfNeeded() 305 306 const authHeaders = getAuthHeaders() 307 if (authHeaders.error) { 308 return { 309 success: false, 310 error: 'Authentication required for policy limits', 311 skipRetry: true, 312 } 313 } 314 315 const endpoint = getPolicyLimitsEndpoint() 316 const headers: Record<string, string> = { 317 ...authHeaders.headers, 318 'User-Agent': getClaudeCodeUserAgent(), 319 } 320 321 if (cachedChecksum) { 322 headers['If-None-Match'] = `"${cachedChecksum}"` 323 } 324 325 const response = await axios.get(endpoint, { 326 headers, 327 timeout: FETCH_TIMEOUT_MS, 328 validateStatus: status => 329 status === 200 || status === 304 || status === 404, 330 }) 331 332 // Handle 304 Not Modified - cached version is still valid 333 if (response.status === 304) { 334 logForDebugging('Policy limits: Using cached restrictions (304)') 335 return { 336 success: true, 337 restrictions: null, // Signal that cache is valid 338 etag: cachedChecksum, 339 } 340 } 341 342 // Handle 404 Not Found - no policy limits exist or feature not enabled 343 if (response.status === 404) { 344 logForDebugging('Policy limits: No restrictions found (404)') 345 return { 346 success: true, 347 restrictions: {}, 348 etag: undefined, 349 } 350 } 351 352 const parsed = PolicyLimitsResponseSchema().safeParse(response.data) 353 if (!parsed.success) { 354 logForDebugging( 355 `Policy limits: Invalid response format - ${parsed.error.message}`, 356 ) 357 return { 358 success: false, 359 error: 'Invalid policy limits format', 360 } 361 } 362 363 logForDebugging('Policy limits: Fetched successfully') 364 return { 365 success: true, 366 restrictions: parsed.data.restrictions, 367 } 368 } catch (error) { 369 // 404 is handled above via validateStatus, so it won't reach here 370 const { kind, message } = classifyAxiosError(error) 371 switch (kind) { 372 case 'auth': 373 return { 374 success: false, 375 error: 'Not authorized for policy limits', 376 skipRetry: true, 377 } 378 case 'timeout': 379 return { success: false, error: 'Policy limits request timeout' } 380 case 'network': 381 return { success: false, error: 'Cannot connect to server' } 382 default: 383 return { success: false, error: message } 384 } 385 } 386 } 387 388 /** 389 * Load restrictions from cache file 390 */ 391 // sync IO: called from sync context (getRestrictionsFromCache -> isPolicyAllowed) 392 function loadCachedRestrictions(): PolicyLimitsResponse['restrictions'] | null { 393 try { 394 const content = fsReadFileSync(getCachePath(), 'utf-8') 395 const data = safeParseJSON(content, false) 396 const parsed = PolicyLimitsResponseSchema().safeParse(data) 397 if (!parsed.success) { 398 return null 399 } 400 401 return parsed.data.restrictions 402 } catch { 403 return null 404 } 405 } 406 407 /** 408 * Save restrictions to cache file 409 */ 410 async function saveCachedRestrictions( 411 restrictions: PolicyLimitsResponse['restrictions'], 412 ): Promise<void> { 413 try { 414 const path = getCachePath() 415 const data: PolicyLimitsResponse = { restrictions } 416 await writeFile(path, jsonStringify(data, null, 2), { 417 encoding: 'utf-8', 418 mode: 0o600, 419 }) 420 logForDebugging(`Policy limits: Saved to ${path}`) 421 } catch (error) { 422 logForDebugging( 423 `Policy limits: Failed to save - ${error instanceof Error ? error.message : 'unknown error'}`, 424 ) 425 } 426 } 427 428 /** 429 * Fetch and load policy limits with file caching 430 * Fails open - returns null if fetch fails and no cache exists 431 */ 432 async function fetchAndLoadPolicyLimits(): Promise< 433 PolicyLimitsResponse['restrictions'] | null 434 > { 435 if (!isPolicyLimitsEligible()) { 436 return null 437 } 438 439 const cachedRestrictions = loadCachedRestrictions() 440 441 const cachedChecksum = cachedRestrictions 442 ? computeChecksum(cachedRestrictions) 443 : undefined 444 445 try { 446 const result = await fetchWithRetry(cachedChecksum) 447 448 if (!result.success) { 449 if (cachedRestrictions) { 450 logForDebugging('Policy limits: Using stale cache after fetch failure') 451 sessionCache = cachedRestrictions 452 return cachedRestrictions 453 } 454 return null 455 } 456 457 // Handle 304 Not Modified 458 if (result.restrictions === null && cachedRestrictions) { 459 logForDebugging('Policy limits: Cache still valid (304 Not Modified)') 460 sessionCache = cachedRestrictions 461 return cachedRestrictions 462 } 463 464 const newRestrictions = result.restrictions || {} 465 const hasContent = Object.keys(newRestrictions).length > 0 466 467 if (hasContent) { 468 sessionCache = newRestrictions 469 await saveCachedRestrictions(newRestrictions) 470 logForDebugging('Policy limits: Applied new restrictions successfully') 471 return newRestrictions 472 } 473 474 // Empty restrictions (404 response) - delete cached file if it exists 475 sessionCache = newRestrictions 476 try { 477 await unlink(getCachePath()) 478 logForDebugging('Policy limits: Deleted cached file (404 response)') 479 } catch (e) { 480 if (isNodeError(e) && e.code !== 'ENOENT') { 481 logForDebugging( 482 `Policy limits: Failed to delete cached file - ${e.message}`, 483 ) 484 } 485 } 486 return newRestrictions 487 } catch { 488 if (cachedRestrictions) { 489 logForDebugging('Policy limits: Using stale cache after error') 490 sessionCache = cachedRestrictions 491 return cachedRestrictions 492 } 493 return null 494 } 495 } 496 497 /** 498 * Policies that default to denied when essential-traffic-only mode is active 499 * and the policy cache is unavailable. Without this, a cache miss or network 500 * timeout would silently re-enable these features for HIPAA orgs. 501 */ 502 const ESSENTIAL_TRAFFIC_DENY_ON_MISS = new Set(['allow_product_feedback']) 503 504 /** 505 * Check if a specific policy is allowed 506 * Returns true if the policy is unknown, unavailable, or explicitly allowed (fail open). 507 * Exception: policies in ESSENTIAL_TRAFFIC_DENY_ON_MISS fail closed when 508 * essential-traffic-only mode is active and the cache is unavailable. 509 */ 510 export function isPolicyAllowed(policy: string): boolean { 511 const restrictions = getRestrictionsFromCache() 512 if (!restrictions) { 513 if ( 514 isEssentialTrafficOnly() && 515 ESSENTIAL_TRAFFIC_DENY_ON_MISS.has(policy) 516 ) { 517 return false 518 } 519 return true // fail open 520 } 521 const restriction = restrictions[policy] 522 if (!restriction) { 523 return true // unknown policy = allowed 524 } 525 return restriction.allowed 526 } 527 528 /** 529 * Get restrictions synchronously from session cache or file 530 */ 531 function getRestrictionsFromCache(): 532 | PolicyLimitsResponse['restrictions'] 533 | null { 534 if (!isPolicyLimitsEligible()) { 535 return null 536 } 537 538 if (sessionCache) { 539 return sessionCache 540 } 541 542 const cachedRestrictions = loadCachedRestrictions() 543 if (cachedRestrictions) { 544 sessionCache = cachedRestrictions 545 return cachedRestrictions 546 } 547 548 return null 549 } 550 551 /** 552 * Load policy limits during CLI initialization 553 * Fails open - if fetch fails, continues without restrictions 554 * Also starts background polling to pick up changes mid-session 555 */ 556 export async function loadPolicyLimits(): Promise<void> { 557 if (isPolicyLimitsEligible() && !loadingCompletePromise) { 558 loadingCompletePromise = new Promise(resolve => { 559 loadingCompleteResolve = resolve 560 }) 561 } 562 563 try { 564 await fetchAndLoadPolicyLimits() 565 566 if (isPolicyLimitsEligible()) { 567 startBackgroundPolling() 568 } 569 } finally { 570 if (loadingCompleteResolve) { 571 loadingCompleteResolve() 572 loadingCompleteResolve = null 573 } 574 } 575 } 576 577 /** 578 * Refresh policy limits asynchronously (for auth state changes) 579 * Used when login occurs 580 */ 581 export async function refreshPolicyLimits(): Promise<void> { 582 await clearPolicyLimitsCache() 583 584 if (!isPolicyLimitsEligible()) { 585 return 586 } 587 588 await fetchAndLoadPolicyLimits() 589 logForDebugging('Policy limits: Refreshed after auth change') 590 } 591 592 /** 593 * Clear all policy limits (session, persistent, and stop polling) 594 */ 595 export async function clearPolicyLimitsCache(): Promise<void> { 596 stopBackgroundPolling() 597 598 sessionCache = null 599 600 loadingCompletePromise = null 601 loadingCompleteResolve = null 602 603 try { 604 await unlink(getCachePath()) 605 } catch { 606 // Ignore errors (including ENOENT when file doesn't exist) 607 } 608 } 609 610 /** 611 * Background polling callback 612 */ 613 async function pollPolicyLimits(): Promise<void> { 614 if (!isPolicyLimitsEligible()) { 615 return 616 } 617 618 const previousCache = sessionCache ? jsonStringify(sessionCache) : null 619 620 try { 621 await fetchAndLoadPolicyLimits() 622 623 const newCache = sessionCache ? jsonStringify(sessionCache) : null 624 if (newCache !== previousCache) { 625 logForDebugging('Policy limits: Changed during background poll') 626 } 627 } catch { 628 // Don't fail closed for background polling 629 } 630 } 631 632 /** 633 * Start background polling for policy limits 634 */ 635 export function startBackgroundPolling(): void { 636 if (pollingIntervalId !== null) { 637 return 638 } 639 640 if (!isPolicyLimitsEligible()) { 641 return 642 } 643 644 pollingIntervalId = setInterval(() => { 645 void pollPolicyLimits() 646 }, POLLING_INTERVAL_MS) 647 pollingIntervalId.unref() 648 649 if (!cleanupRegistered) { 650 cleanupRegistered = true 651 registerCleanup(async () => stopBackgroundPolling()) 652 } 653 } 654 655 /** 656 * Stop background polling for policy limits 657 */ 658 export function stopBackgroundPolling(): void { 659 if (pollingIntervalId !== null) { 660 clearInterval(pollingIntervalId) 661 pollingIntervalId = null 662 } 663 }