index.ts 18 KB

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