/ utils / settings / settings.ts
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  }