/ services / policyLimits / index.ts
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  }