settings.ts
1 import { feature } from 'bun:bundle' 2 import mergeWith from 'lodash-es/mergeWith.js' 3 import { dirname, join, resolve } from 'path' 4 import { z } from 'zod/v4' 5 import { 6 getFlagSettingsInline, 7 getFlagSettingsPath, 8 getOriginalCwd, 9 getUseCoworkPlugins, 10 } from '../../bootstrap/state.js' 11 import { getRemoteManagedSettingsSyncFromCache } from '../../services/remoteManagedSettings/syncCacheState.js' 12 import { uniq } from '../array.js' 13 import { logForDebugging } from '../debug.js' 14 import { logForDiagnosticsNoPII } from '../diagLogs.js' 15 import { getClaudeConfigHomeDir, isEnvTruthy } from '../envUtils.js' 16 import { getErrnoCode, isENOENT } from '../errors.js' 17 import { writeFileSyncAndFlush_DEPRECATED } from '../file.js' 18 import { readFileSync } from '../fileRead.js' 19 import { getFsImplementation, safeResolvePath } from '../fsOperations.js' 20 import { addFileGlobRuleToGitignore } from '../git/gitignore.js' 21 import { safeParseJSON } from '../json.js' 22 import { logError } from '../log.js' 23 import { getPlatform } from '../platform.js' 24 import { clone, jsonStringify } from '../slowOperations.js' 25 import { profileCheckpoint } from '../startupProfiler.js' 26 import { 27 type EditableSettingSource, 28 getEnabledSettingSources, 29 type SettingSource, 30 } from './constants.js' 31 import { markInternalWrite } from './internalWrites.js' 32 import { 33 getManagedFilePath, 34 getManagedSettingsDropInDir, 35 } from './managedPath.js' 36 import { getHkcuSettings, getMdmSettings } from './mdm/settings.js' 37 import { 38 getCachedParsedFile, 39 getCachedSettingsForSource, 40 getPluginSettingsBase, 41 getSessionSettingsCache, 42 resetSettingsCache, 43 setCachedParsedFile, 44 setCachedSettingsForSource, 45 setSessionSettingsCache, 46 } from './settingsCache.js' 47 import { type SettingsJson, SettingsSchema } from './types.js' 48 import { 49 filterInvalidPermissionRules, 50 formatZodError, 51 type SettingsWithErrors, 52 type ValidationError, 53 } from './validation.js' 54 55 /** 56 * Get the path to the managed settings file based on the current platform 57 */ 58 function getManagedSettingsFilePath(): string { 59 return join(getManagedFilePath(), 'managed-settings.json') 60 } 61 62 /** 63 * Load file-based managed settings: managed-settings.json + managed-settings.d/*.json. 64 * 65 * managed-settings.json is merged first (lowest precedence / base), then drop-in 66 * files are sorted alphabetically and merged on top (higher precedence, later 67 * files win). This matches the systemd/sudoers drop-in convention: the base 68 * file provides defaults, drop-ins customize. Separate teams can ship 69 * independent policy fragments (e.g. 10-otel.json, 20-security.json) without 70 * coordinating edits to a single admin-owned file. 71 * 72 * Exported for testing. 73 */ 74 export function loadManagedFileSettings(): { 75 settings: SettingsJson | null 76 errors: ValidationError[] 77 } { 78 const errors: ValidationError[] = [] 79 let merged: SettingsJson = {} 80 let found = false 81 82 const { settings, errors: baseErrors } = parseSettingsFile( 83 getManagedSettingsFilePath(), 84 ) 85 errors.push(...baseErrors) 86 if (settings && Object.keys(settings).length > 0) { 87 merged = mergeWith(merged, settings, settingsMergeCustomizer) 88 found = true 89 } 90 91 const dropInDir = getManagedSettingsDropInDir() 92 try { 93 const entries = getFsImplementation() 94 .readdirSync(dropInDir) 95 .filter( 96 d => 97 (d.isFile() || d.isSymbolicLink()) && 98 d.name.endsWith('.json') && 99 !d.name.startsWith('.'), 100 ) 101 .map(d => d.name) 102 .sort() 103 for (const name of entries) { 104 const { settings, errors: fileErrors } = parseSettingsFile( 105 join(dropInDir, name), 106 ) 107 errors.push(...fileErrors) 108 if (settings && Object.keys(settings).length > 0) { 109 merged = mergeWith(merged, settings, settingsMergeCustomizer) 110 found = true 111 } 112 } 113 } catch (e) { 114 const code = getErrnoCode(e) 115 if (code !== 'ENOENT' && code !== 'ENOTDIR') { 116 logError(e) 117 } 118 } 119 120 return { settings: found ? merged : null, errors } 121 } 122 123 /** 124 * Check which file-based managed settings sources are present. 125 * Used by /status to show "(file)", "(drop-ins)", or "(file + drop-ins)". 126 */ 127 export function getManagedFileSettingsPresence(): { 128 hasBase: boolean 129 hasDropIns: boolean 130 } { 131 const { settings: base } = parseSettingsFile(getManagedSettingsFilePath()) 132 const hasBase = !!base && Object.keys(base).length > 0 133 134 let hasDropIns = false 135 const dropInDir = getManagedSettingsDropInDir() 136 try { 137 hasDropIns = getFsImplementation() 138 .readdirSync(dropInDir) 139 .some( 140 d => 141 (d.isFile() || d.isSymbolicLink()) && 142 d.name.endsWith('.json') && 143 !d.name.startsWith('.'), 144 ) 145 } catch { 146 // dir doesn't exist 147 } 148 149 return { hasBase, hasDropIns } 150 } 151 152 /** 153 * Handles file system errors appropriately 154 * @param error The error to handle 155 * @param path The file path that caused the error 156 */ 157 function handleFileSystemError(error: unknown, path: string): void { 158 if ( 159 typeof error === 'object' && 160 error && 161 'code' in error && 162 error.code === 'ENOENT' 163 ) { 164 logForDebugging( 165 `Broken symlink or missing file encountered for settings.json at path: ${path}`, 166 ) 167 } else { 168 logError(error) 169 } 170 } 171 172 /** 173 * Parses a settings file into a structured format 174 * @param path The path to the permissions file 175 * @param source The source of the settings (optional, for error reporting) 176 * @returns Parsed settings data and validation errors 177 */ 178 export function parseSettingsFile(path: string): { 179 settings: SettingsJson | null 180 errors: ValidationError[] 181 } { 182 const cached = getCachedParsedFile(path) 183 if (cached) { 184 // Clone so callers (e.g. mergeWith in getSettingsForSourceUncached, 185 // updateSettingsForSource) can't mutate the cached entry. 186 return { 187 settings: cached.settings ? clone(cached.settings) : null, 188 errors: cached.errors, 189 } 190 } 191 const result = parseSettingsFileUncached(path) 192 setCachedParsedFile(path, result) 193 // Clone the first return too — the caller may mutate before 194 // another caller reads the same cache entry. 195 return { 196 settings: result.settings ? clone(result.settings) : null, 197 errors: result.errors, 198 } 199 } 200 201 function parseSettingsFileUncached(path: string): { 202 settings: SettingsJson | null 203 errors: ValidationError[] 204 } { 205 try { 206 const { resolvedPath } = safeResolvePath(getFsImplementation(), path) 207 const content = readFileSync(resolvedPath) 208 209 if (content.trim() === '') { 210 return { settings: {}, errors: [] } 211 } 212 213 const data = safeParseJSON(content, false) 214 215 // Filter invalid permission rules before schema validation so one bad 216 // rule doesn't cause the entire settings file to be rejected. 217 const ruleWarnings = filterInvalidPermissionRules(data, path) 218 219 const result = SettingsSchema().safeParse(data) 220 221 if (!result.success) { 222 const errors = formatZodError(result.error, path) 223 return { settings: null, errors: [...ruleWarnings, ...errors] } 224 } 225 226 return { settings: result.data, errors: ruleWarnings } 227 } catch (error) { 228 handleFileSystemError(error, path) 229 return { settings: null, errors: [] } 230 } 231 } 232 233 /** 234 * Get the absolute path to the associated file root for a given settings source 235 * (e.g. for $PROJ_DIR/.claude/settings.json, returns $PROJ_DIR) 236 * @param source The source of the settings 237 * @returns The root path of the settings file 238 */ 239 export function getSettingsRootPathForSource(source: SettingSource): string { 240 switch (source) { 241 case 'userSettings': 242 return resolve(getClaudeConfigHomeDir()) 243 case 'policySettings': 244 case 'projectSettings': 245 case 'localSettings': { 246 return resolve(getOriginalCwd()) 247 } 248 case 'flagSettings': { 249 const path = getFlagSettingsPath() 250 return path ? dirname(resolve(path)) : resolve(getOriginalCwd()) 251 } 252 } 253 } 254 255 /** 256 * Get the user settings filename based on cowork mode. 257 * Returns 'cowork_settings.json' when in cowork mode, 'settings.json' otherwise. 258 * 259 * Priority: 260 * 1. Session state (set by CLI flag --cowork) 261 * 2. Environment variable CLAUDE_CODE_USE_COWORK_PLUGINS 262 * 3. Default: 'settings.json' 263 */ 264 function getUserSettingsFilePath(): string { 265 if ( 266 getUseCoworkPlugins() || 267 isEnvTruthy(process.env.CLAUDE_CODE_USE_COWORK_PLUGINS) 268 ) { 269 return 'cowork_settings.json' 270 } 271 return 'settings.json' 272 } 273 274 export function getSettingsFilePathForSource( 275 source: SettingSource, 276 ): string | undefined { 277 switch (source) { 278 case 'userSettings': 279 return join( 280 getSettingsRootPathForSource(source), 281 getUserSettingsFilePath(), 282 ) 283 case 'projectSettings': 284 case 'localSettings': { 285 return join( 286 getSettingsRootPathForSource(source), 287 getRelativeSettingsFilePathForSource(source), 288 ) 289 } 290 case 'policySettings': 291 return getManagedSettingsFilePath() 292 case 'flagSettings': { 293 return getFlagSettingsPath() 294 } 295 } 296 } 297 298 export function getRelativeSettingsFilePathForSource( 299 source: 'projectSettings' | 'localSettings', 300 ): string { 301 switch (source) { 302 case 'projectSettings': 303 return join('.claude', 'settings.json') 304 case 'localSettings': 305 return join('.claude', 'settings.local.json') 306 } 307 } 308 309 export function getSettingsForSource( 310 source: SettingSource, 311 ): SettingsJson | null { 312 const cached = getCachedSettingsForSource(source) 313 if (cached !== undefined) return cached 314 const result = getSettingsForSourceUncached(source) 315 setCachedSettingsForSource(source, result) 316 return result 317 } 318 319 function getSettingsForSourceUncached( 320 source: SettingSource, 321 ): SettingsJson | null { 322 // For policySettings: first source wins (remote > HKLM/plist > file > HKCU) 323 if (source === 'policySettings') { 324 const remoteSettings = getRemoteManagedSettingsSyncFromCache() 325 if (remoteSettings && Object.keys(remoteSettings).length > 0) { 326 return remoteSettings 327 } 328 329 const mdmResult = getMdmSettings() 330 if (Object.keys(mdmResult.settings).length > 0) { 331 return mdmResult.settings 332 } 333 334 const { settings: fileSettings } = loadManagedFileSettings() 335 if (fileSettings) { 336 return fileSettings 337 } 338 339 const hkcu = getHkcuSettings() 340 if (Object.keys(hkcu.settings).length > 0) { 341 return hkcu.settings 342 } 343 344 return null 345 } 346 347 const settingsFilePath = getSettingsFilePathForSource(source) 348 const { settings: fileSettings } = settingsFilePath 349 ? parseSettingsFile(settingsFilePath) 350 : { settings: null } 351 352 // For flagSettings, merge in any inline settings set via the SDK 353 if (source === 'flagSettings') { 354 const inlineSettings = getFlagSettingsInline() 355 if (inlineSettings) { 356 const parsed = SettingsSchema().safeParse(inlineSettings) 357 if (parsed.success) { 358 return mergeWith( 359 fileSettings || {}, 360 parsed.data, 361 settingsMergeCustomizer, 362 ) as SettingsJson 363 } 364 } 365 } 366 367 return fileSettings 368 } 369 370 /** 371 * Get the origin of the highest-priority active policy settings source. 372 * Uses "first source wins" — returns the first source that has content. 373 * Priority: remote > plist/hklm > file (managed-settings.json) > hkcu 374 */ 375 export function getPolicySettingsOrigin(): 376 | 'remote' 377 | 'plist' 378 | 'hklm' 379 | 'file' 380 | 'hkcu' 381 | null { 382 // 1. Remote (highest) 383 const remoteSettings = getRemoteManagedSettingsSyncFromCache() 384 if (remoteSettings && Object.keys(remoteSettings).length > 0) { 385 return 'remote' 386 } 387 388 // 2. Admin-only MDM (HKLM / macOS plist) 389 const mdmResult = getMdmSettings() 390 if (Object.keys(mdmResult.settings).length > 0) { 391 return getPlatform() === 'macos' ? 'plist' : 'hklm' 392 } 393 394 // 3. managed-settings.json + managed-settings.d/ (file-based, requires admin) 395 const { settings: fileSettings } = loadManagedFileSettings() 396 if (fileSettings) { 397 return 'file' 398 } 399 400 // 4. HKCU (lowest — user-writable) 401 const hkcu = getHkcuSettings() 402 if (Object.keys(hkcu.settings).length > 0) { 403 return 'hkcu' 404 } 405 406 return null 407 } 408 409 /** 410 * Merges `settings` into the existing settings for `source` using lodash mergeWith. 411 * 412 * To delete a key from a record field (e.g. enabledPlugins, extraKnownMarketplaces), 413 * set it to `undefined` — do NOT use `delete`. mergeWith only detects deletion when 414 * the key is present with an explicit `undefined` value. 415 */ 416 export function updateSettingsForSource( 417 source: EditableSettingSource, 418 settings: SettingsJson, 419 ): { error: Error | null } { 420 if ( 421 (source as unknown) === 'policySettings' || 422 (source as unknown) === 'flagSettings' 423 ) { 424 return { error: null } 425 } 426 427 // Create the folder if needed 428 const filePath = getSettingsFilePathForSource(source) 429 if (!filePath) { 430 return { error: null } 431 } 432 433 try { 434 getFsImplementation().mkdirSync(dirname(filePath)) 435 436 // Try to get existing settings with validation. Bypass the per-source 437 // cache — mergeWith below mutates its target (including nested refs), 438 // and mutating the cached object would leak unpersisted state if the 439 // write fails before resetSettingsCache(). 440 let existingSettings = getSettingsForSourceUncached(source) 441 442 // If validation failed, check if file exists with a JSON syntax error 443 if (!existingSettings) { 444 let content: string | null = null 445 try { 446 content = readFileSync(filePath) 447 } catch (e) { 448 if (!isENOENT(e)) { 449 throw e 450 } 451 // File doesn't exist — fall through to merge with empty settings 452 } 453 if (content !== null) { 454 const rawData = safeParseJSON(content) 455 if (rawData === null) { 456 // JSON syntax error - return validation error instead of overwriting 457 // safeParseJSON will already log the error, so we'll just return the error here 458 return { 459 error: new Error( 460 `Invalid JSON syntax in settings file at ${filePath}`, 461 ), 462 } 463 } 464 if (rawData && typeof rawData === 'object') { 465 existingSettings = rawData as SettingsJson 466 logForDebugging( 467 `Using raw settings from ${filePath} due to validation failure`, 468 ) 469 } 470 } 471 } 472 473 const updatedSettings = mergeWith( 474 existingSettings || {}, 475 settings, 476 ( 477 _objValue: unknown, 478 srcValue: unknown, 479 key: string | number | symbol, 480 object: Record<string | number | symbol, unknown>, 481 ) => { 482 // Handle undefined as deletion 483 if (srcValue === undefined && object && typeof key === 'string') { 484 delete object[key] 485 return undefined 486 } 487 // For arrays, always replace with the provided array 488 // This puts the responsibility on the caller to compute the desired final state 489 if (Array.isArray(srcValue)) { 490 return srcValue 491 } 492 // For non-arrays, let lodash handle the default merge behavior 493 return undefined 494 }, 495 ) 496 497 // Mark this as an internal write before writing the file 498 markInternalWrite(filePath) 499 500 writeFileSyncAndFlush_DEPRECATED( 501 filePath, 502 jsonStringify(updatedSettings, null, 2) + '\n', 503 ) 504 505 // Invalidate the session cache since settings have been updated 506 resetSettingsCache() 507 508 if (source === 'localSettings') { 509 // Okay to add to gitignore async without awaiting 510 void addFileGlobRuleToGitignore( 511 getRelativeSettingsFilePathForSource('localSettings'), 512 getOriginalCwd(), 513 ) 514 } 515 } catch (e) { 516 const error = new Error( 517 `Failed to read raw settings from ${filePath}: ${e}`, 518 ) 519 logError(error) 520 return { error } 521 } 522 523 return { error: null } 524 } 525 526 /** 527 * Custom merge function for arrays - concatenate and deduplicate 528 */ 529 function mergeArrays<T>(targetArray: T[], sourceArray: T[]): T[] { 530 return uniq([...targetArray, ...sourceArray]) 531 } 532 533 /** 534 * Custom merge function for lodash mergeWith when merging settings. 535 * Arrays are concatenated and deduplicated; other values use default lodash merge behavior. 536 * Exported for testing. 537 */ 538 export function settingsMergeCustomizer( 539 objValue: unknown, 540 srcValue: unknown, 541 ): unknown { 542 if (Array.isArray(objValue) && Array.isArray(srcValue)) { 543 return mergeArrays(objValue, srcValue) 544 } 545 // Return undefined to let lodash handle default merge behavior 546 return undefined 547 } 548 549 /** 550 * Get a list of setting keys from managed settings for logging purposes. 551 * For certain nested settings (permissions, sandbox, hooks), expands to show 552 * one level of nesting (e.g., "permissions.allow"). For other settings, 553 * returns only the top-level key. 554 * 555 * @param settings The settings object to extract keys from 556 * @returns Sorted array of key paths 557 */ 558 export function getManagedSettingsKeysForLogging( 559 settings: SettingsJson, 560 ): string[] { 561 // Use .strip() to get only valid schema keys 562 const validSettings = SettingsSchema().strip().parse(settings) as Record< 563 string, 564 unknown 565 > 566 const keysToExpand = ['permissions', 'sandbox', 'hooks'] 567 const allKeys: string[] = [] 568 569 // Define valid nested keys for each nested setting we expand 570 const validNestedKeys: Record<string, Set<string>> = { 571 permissions: new Set([ 572 'allow', 573 'deny', 574 'ask', 575 'defaultMode', 576 'disableBypassPermissionsMode', 577 ...(feature('TRANSCRIPT_CLASSIFIER') ? ['disableAutoMode'] : []), 578 'additionalDirectories', 579 ]), 580 sandbox: new Set([ 581 'enabled', 582 'failIfUnavailable', 583 'allowUnsandboxedCommands', 584 'network', 585 'filesystem', 586 'ignoreViolations', 587 'excludedCommands', 588 'autoAllowBashIfSandboxed', 589 'enableWeakerNestedSandbox', 590 'enableWeakerNetworkIsolation', 591 'ripgrep', 592 ]), 593 // For hooks, we use z.record with enum keys, so we validate separately 594 hooks: new Set([ 595 'PreToolUse', 596 'PostToolUse', 597 'Notification', 598 'UserPromptSubmit', 599 'SessionStart', 600 'SessionEnd', 601 'Stop', 602 'SubagentStop', 603 'PreCompact', 604 'PostCompact', 605 'TeammateIdle', 606 'TaskCreated', 607 'TaskCompleted', 608 ]), 609 } 610 611 for (const key of Object.keys(validSettings)) { 612 if ( 613 keysToExpand.includes(key) && 614 validSettings[key] && 615 typeof validSettings[key] === 'object' 616 ) { 617 // Expand nested keys for these special settings (one level deep only) 618 const nestedObj = validSettings[key] as Record<string, unknown> 619 const validKeys = validNestedKeys[key] 620 621 if (validKeys) { 622 for (const nestedKey of Object.keys(nestedObj)) { 623 // Only include known valid nested keys 624 if (validKeys.has(nestedKey)) { 625 allKeys.push(`${key}.${nestedKey}`) 626 } 627 } 628 } 629 } else { 630 // For other settings, just use the top-level key 631 allKeys.push(key) 632 } 633 } 634 635 return allKeys.sort() 636 } 637 638 // Flag to prevent infinite recursion when loading settings 639 let isLoadingSettings = false 640 641 /** 642 * Load settings from disk without using cache 643 * This is the original implementation that actually reads from files 644 */ 645 function loadSettingsFromDisk(): SettingsWithErrors { 646 // Prevent recursive calls to loadSettingsFromDisk 647 if (isLoadingSettings) { 648 return { settings: {}, errors: [] } 649 } 650 651 const startTime = Date.now() 652 profileCheckpoint('loadSettingsFromDisk_start') 653 logForDiagnosticsNoPII('info', 'settings_load_started') 654 655 isLoadingSettings = true 656 try { 657 // Start with plugin settings as the lowest priority base. 658 // All file-based sources (user, project, local, flag, policy) override these. 659 // Plugin settings only contain allowlisted keys (e.g., agent) that are valid SettingsJson fields. 660 const pluginSettings = getPluginSettingsBase() 661 let mergedSettings: SettingsJson = {} 662 if (pluginSettings) { 663 mergedSettings = mergeWith( 664 mergedSettings, 665 pluginSettings, 666 settingsMergeCustomizer, 667 ) 668 } 669 const allErrors: ValidationError[] = [] 670 const seenErrors = new Set<string>() 671 const seenFiles = new Set<string>() 672 673 // Merge settings from each source in priority order with deep merging 674 for (const source of getEnabledSettingSources()) { 675 // policySettings: "first source wins" — use the highest-priority source 676 // that has content. Priority: remote > HKLM/plist > managed-settings.json > HKCU 677 if (source === 'policySettings') { 678 let policySettings: SettingsJson | null = null 679 const policyErrors: ValidationError[] = [] 680 681 // 1. Remote (highest priority) 682 const remoteSettings = getRemoteManagedSettingsSyncFromCache() 683 if (remoteSettings && Object.keys(remoteSettings).length > 0) { 684 const result = SettingsSchema().safeParse(remoteSettings) 685 if (result.success) { 686 policySettings = result.data 687 } else { 688 // Remote exists but is invalid — surface errors even as we fall through 689 policyErrors.push( 690 ...formatZodError(result.error, 'remote managed settings'), 691 ) 692 } 693 } 694 695 // 2. Admin-only MDM (HKLM / macOS plist) 696 if (!policySettings) { 697 const mdmResult = getMdmSettings() 698 if (Object.keys(mdmResult.settings).length > 0) { 699 policySettings = mdmResult.settings 700 } 701 policyErrors.push(...mdmResult.errors) 702 } 703 704 // 3. managed-settings.json + managed-settings.d/ (file-based, requires admin) 705 if (!policySettings) { 706 const { settings, errors } = loadManagedFileSettings() 707 if (settings) { 708 policySettings = settings 709 } 710 policyErrors.push(...errors) 711 } 712 713 // 4. HKCU (lowest — user-writable, only if nothing above exists) 714 if (!policySettings) { 715 const hkcu = getHkcuSettings() 716 if (Object.keys(hkcu.settings).length > 0) { 717 policySettings = hkcu.settings 718 } 719 policyErrors.push(...hkcu.errors) 720 } 721 722 // Merge the winning policy source into the settings chain 723 if (policySettings) { 724 mergedSettings = mergeWith( 725 mergedSettings, 726 policySettings, 727 settingsMergeCustomizer, 728 ) 729 } 730 for (const error of policyErrors) { 731 const errorKey = `${error.file}:${error.path}:${error.message}` 732 if (!seenErrors.has(errorKey)) { 733 seenErrors.add(errorKey) 734 allErrors.push(error) 735 } 736 } 737 738 continue 739 } 740 741 const filePath = getSettingsFilePathForSource(source) 742 if (filePath) { 743 const resolvedPath = resolve(filePath) 744 745 // Skip if we've already loaded this file from another source 746 if (!seenFiles.has(resolvedPath)) { 747 seenFiles.add(resolvedPath) 748 749 const { settings, errors } = parseSettingsFile(filePath) 750 751 // Add unique errors (deduplication) 752 for (const error of errors) { 753 const errorKey = `${error.file}:${error.path}:${error.message}` 754 if (!seenErrors.has(errorKey)) { 755 seenErrors.add(errorKey) 756 allErrors.push(error) 757 } 758 } 759 760 if (settings) { 761 mergedSettings = mergeWith( 762 mergedSettings, 763 settings, 764 settingsMergeCustomizer, 765 ) 766 } 767 } 768 } 769 770 // For flagSettings, also merge any inline settings set via the SDK 771 if (source === 'flagSettings') { 772 const inlineSettings = getFlagSettingsInline() 773 if (inlineSettings) { 774 const parsed = SettingsSchema().safeParse(inlineSettings) 775 if (parsed.success) { 776 mergedSettings = mergeWith( 777 mergedSettings, 778 parsed.data, 779 settingsMergeCustomizer, 780 ) 781 } 782 } 783 } 784 } 785 786 logForDiagnosticsNoPII('info', 'settings_load_completed', { 787 duration_ms: Date.now() - startTime, 788 source_count: seenFiles.size, 789 error_count: allErrors.length, 790 }) 791 792 return { settings: mergedSettings, errors: allErrors } 793 } finally { 794 isLoadingSettings = false 795 } 796 } 797 798 /** 799 * Get merged settings from all sources in priority order 800 * Settings are merged from lowest to highest priority: 801 * userSettings -> projectSettings -> localSettings -> policySettings 802 * 803 * This function returns a snapshot of settings at the time of call. 804 * For React components, prefer using useSettings() hook for reactive updates 805 * when settings change on disk. 806 * 807 * Uses session-level caching to avoid repeated file I/O. 808 * Cache is invalidated when settings files change via resetSettingsCache(). 809 * 810 * @returns Merged settings from all available sources (always returns at least empty object) 811 */ 812 export function getInitialSettings(): SettingsJson { 813 const { settings } = getSettingsWithErrors() 814 return settings || {} 815 } 816 817 /** 818 * @deprecated Use getInitialSettings() instead. This alias exists for backwards compatibility. 819 */ 820 export const getSettings_DEPRECATED = getInitialSettings 821 822 export type SettingsWithSources = { 823 effective: SettingsJson 824 /** Ordered low-to-high priority — later entries override earlier ones. */ 825 sources: Array<{ source: SettingSource; settings: SettingsJson }> 826 } 827 828 /** 829 * Get the effective merged settings alongside the raw per-source settings, 830 * in merge-priority order. Only includes sources that are enabled and have 831 * non-empty content. 832 * 833 * Always reads fresh from disk — resets the session cache so that `effective` 834 * and `sources` are consistent even if the change detector hasn't fired yet. 835 */ 836 export function getSettingsWithSources(): SettingsWithSources { 837 // Reset both caches so getSettingsForSource (per-source cache) and 838 // getInitialSettings (session cache) agree on the current disk state. 839 resetSettingsCache() 840 const sources: SettingsWithSources['sources'] = [] 841 for (const source of getEnabledSettingSources()) { 842 const settings = getSettingsForSource(source) 843 if (settings && Object.keys(settings).length > 0) { 844 sources.push({ source, settings }) 845 } 846 } 847 return { effective: getInitialSettings(), sources } 848 } 849 850 /** 851 * Get merged settings and validation errors from all sources 852 * This function now uses session-level caching to avoid repeated file I/O. 853 * Settings changes require Claude Code restart, so cache is valid for entire session. 854 * @returns Merged settings and all validation errors encountered 855 */ 856 export function getSettingsWithErrors(): SettingsWithErrors { 857 // Use cached result if available 858 const cached = getSessionSettingsCache() 859 if (cached !== null) { 860 return cached 861 } 862 863 // Load from disk and cache the result 864 const result = loadSettingsFromDisk() 865 profileCheckpoint('loadSettingsFromDisk_end') 866 setSessionSettingsCache(result) 867 return result 868 } 869 870 /** 871 * Check if any raw settings file contains a specific key, regardless of validation. 872 * This is useful for detecting user intent even when settings validation fails. 873 * For example, if a user set cleanupPeriodDays but has validation errors elsewhere, 874 * we can detect they explicitly configured cleanup and skip cleanup rather than 875 * falling back to defaults. 876 */ 877 /** 878 * Returns true if any trusted settings source has accepted the bypass 879 * permissions mode dialog. projectSettings is intentionally excluded — 880 * a malicious project could otherwise auto-bypass the dialog (RCE risk). 881 */ 882 export function hasSkipDangerousModePermissionPrompt(): boolean { 883 return !!( 884 getSettingsForSource('userSettings')?.skipDangerousModePermissionPrompt || 885 getSettingsForSource('localSettings')?.skipDangerousModePermissionPrompt || 886 getSettingsForSource('flagSettings')?.skipDangerousModePermissionPrompt || 887 getSettingsForSource('policySettings')?.skipDangerousModePermissionPrompt 888 ) 889 } 890 891 /** 892 * Returns true if any trusted settings source has accepted the auto 893 * mode opt-in dialog. projectSettings is intentionally excluded — 894 * a malicious project could otherwise auto-bypass the dialog (RCE risk). 895 */ 896 export function hasAutoModeOptIn(): boolean { 897 if (feature('TRANSCRIPT_CLASSIFIER')) { 898 const user = getSettingsForSource('userSettings')?.skipAutoPermissionPrompt 899 const local = 900 getSettingsForSource('localSettings')?.skipAutoPermissionPrompt 901 const flag = getSettingsForSource('flagSettings')?.skipAutoPermissionPrompt 902 const policy = 903 getSettingsForSource('policySettings')?.skipAutoPermissionPrompt 904 const result = !!(user || local || flag || policy) 905 logForDebugging( 906 `[auto-mode] hasAutoModeOptIn=${result} skipAutoPermissionPrompt: user=${user} local=${local} flag=${flag} policy=${policy}`, 907 ) 908 return result 909 } 910 return false 911 } 912 913 /** 914 * Returns whether plan mode should use auto mode semantics. Default true 915 * (opt-out). Returns false if any trusted source explicitly sets false. 916 * projectSettings is excluded so a malicious project can't control this. 917 */ 918 export function getUseAutoModeDuringPlan(): boolean { 919 if (feature('TRANSCRIPT_CLASSIFIER')) { 920 return ( 921 getSettingsForSource('policySettings')?.useAutoModeDuringPlan !== false && 922 getSettingsForSource('flagSettings')?.useAutoModeDuringPlan !== false && 923 getSettingsForSource('userSettings')?.useAutoModeDuringPlan !== false && 924 getSettingsForSource('localSettings')?.useAutoModeDuringPlan !== false 925 ) 926 } 927 return true 928 } 929 930 /** 931 * Returns the merged autoMode config from trusted settings sources. 932 * Only available when TRANSCRIPT_CLASSIFIER is active; returns undefined otherwise. 933 * projectSettings is intentionally excluded — a malicious project could 934 * otherwise inject classifier allow/deny rules (RCE risk). 935 */ 936 export function getAutoModeConfig(): 937 | { allow?: string[]; soft_deny?: string[]; environment?: string[] } 938 | undefined { 939 if (feature('TRANSCRIPT_CLASSIFIER')) { 940 const schema = z.object({ 941 allow: z.array(z.string()).optional(), 942 soft_deny: z.array(z.string()).optional(), 943 deny: z.array(z.string()).optional(), 944 environment: z.array(z.string()).optional(), 945 }) 946 947 const allow: string[] = [] 948 const soft_deny: string[] = [] 949 const environment: string[] = [] 950 951 for (const source of [ 952 'userSettings', 953 'localSettings', 954 'flagSettings', 955 'policySettings', 956 ] as const) { 957 const settings = getSettingsForSource(source) 958 if (!settings) continue 959 const result = schema.safeParse( 960 (settings as Record<string, unknown>).autoMode, 961 ) 962 if (result.success) { 963 if (result.data.allow) allow.push(...result.data.allow) 964 if (result.data.soft_deny) soft_deny.push(...result.data.soft_deny) 965 if (process.env.USER_TYPE === 'ant') { 966 if (result.data.deny) soft_deny.push(...result.data.deny) 967 } 968 if (result.data.environment) 969 environment.push(...result.data.environment) 970 } 971 } 972 973 if (allow.length > 0 || soft_deny.length > 0 || environment.length > 0) { 974 return { 975 ...(allow.length > 0 && { allow }), 976 ...(soft_deny.length > 0 && { soft_deny }), 977 ...(environment.length > 0 && { environment }), 978 } 979 } 980 } 981 return undefined 982 } 983 984 export function rawSettingsContainsKey(key: string): boolean { 985 for (const source of getEnabledSettingSources()) { 986 // Skip policySettings - we only care about user-configured settings 987 if (source === 'policySettings') { 988 continue 989 } 990 991 const filePath = getSettingsFilePathForSource(source) 992 if (!filePath) { 993 continue 994 } 995 996 try { 997 const { resolvedPath } = safeResolvePath(getFsImplementation(), filePath) 998 const content = readFileSync(resolvedPath) 999 if (!content.trim()) { 1000 continue 1001 } 1002 1003 const rawData = safeParseJSON(content, false) 1004 if (rawData && typeof rawData === 'object' && key in rawData) { 1005 return true 1006 } 1007 } catch (error) { 1008 // File not found is expected - not all settings files exist 1009 // Other errors (permissions, I/O) should be tracked 1010 handleFileSystemError(error, filePath) 1011 } 1012 } 1013 1014 return false 1015 }