Download src/utils/settings/settings.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 32.2 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/settings/settings.ts
- Command line
-
hf download hf://chenbhao/codev/src/utils/settings/settings.ts
-
curl -L -o settings.ts https://huggingface.co/chenbhao/codev/resolve/main/src/utils/settings/settings.ts
32.2 kB
| import { feature } from 'bun:bundle' | |
| import mergeWith from 'lodash-es/mergeWith.js' | |
| import { dirname, join, resolve } from 'path' | |
| import { z } from 'zod/v4' | |
| import { | |
| getFlagSettingsInline, | |
| getFlagSettingsPath, | |
| getOriginalCwd, | |
| getUseCoworkPlugins, | |
| } from '../../bootstrap/state.js' | |
| import { getRemoteManagedSettingsSyncFromCache } from '../../services/remoteManagedSettings/syncCacheState.js' | |
| import { uniq } from '../array.js' | |
| import { logForDebugging } from '../debug.js' | |
| import { logForDiagnosticsNoPII } from '../diagLogs.js' | |
| import { getClaudeConfigHomeDir, isEnvTruthy } from '../envUtils.js' | |
| import { getErrnoCode, isENOENT } from '../errors.js' | |
| import { writeFileSyncAndFlush_DEPRECATED } from '../file.js' | |
| import { readFileSync } from '../fileRead.js' | |
| import { getFsImplementation, safeResolvePath } from '../fsOperations.js' | |
| import { addFileGlobRuleToGitignore } from '../git/gitignore.js' | |
| import { safeParseJSON } from '../json.js' | |
| import { logError } from '../log.js' | |
| import { getPlatform } from '../platform.js' | |
| import { clone, jsonStringify } from '../slowOperations.js' | |
| import { profileCheckpoint } from '../startupProfiler.js' | |
| import { | |
| type EditableSettingSource, | |
| getEnabledSettingSources, | |
| type SettingSource, | |
| } from './constants.js' | |
| import { markInternalWrite } from './internalWrites.js' | |
| import { | |
| getManagedFilePath, | |
| getManagedSettingsDropInDir, | |
| } from './managedPath.js' | |
| import { getHkcuSettings, getMdmSettings } from './mdm/settings.js' | |
| import { | |
| getCachedParsedFile, | |
| getCachedSettingsForSource, | |
| getPluginSettingsBase, | |
| getSessionSettingsCache, | |
| resetSettingsCache, | |
| setCachedParsedFile, | |
| setCachedSettingsForSource, | |
| setSessionSettingsCache, | |
| } from './settingsCache.js' | |
| import { type SettingsJson, SettingsSchema } from './types.js' | |
| import { | |
| filterInvalidPermissionRules, | |
| formatZodError, | |
| type SettingsWithErrors, | |
| type ValidationError, | |
| } from './validation.js' | |
| /** | |
| * Get the path to the managed settings file based on the current platform | |
| */ | |
| function getManagedSettingsFilePath(): string { | |
| return join(getManagedFilePath(), 'managed-settings.json') | |
| } | |
| /** | |
| * Load file-based managed settings: managed-settings.json + managed-settings.d/*.json. | |
| * | |
| * managed-settings.json is merged first (lowest precedence / base), then drop-in | |
| * files are sorted alphabetically and merged on top (higher precedence, later | |
| * files win). This matches the systemd/sudoers drop-in convention: the base | |
| * file provides defaults, drop-ins customize. Separate teams can ship | |
| * independent policy fragments (e.g. 10-otel.json, 20-security.json) without | |
| * coordinating edits to a single admin-owned file. | |
| * | |
| * Exported for testing. | |
| */ | |
| export function loadManagedFileSettings(): { | |
| settings: SettingsJson | null | |
| errors: ValidationError[] | |
| } { | |
| const errors: ValidationError[] = [] | |
| let merged: SettingsJson = {} | |
| let found = false | |
| const { settings, errors: baseErrors } = parseSettingsFile( | |
| getManagedSettingsFilePath(), | |
| ) | |
| errors.push(...baseErrors) | |
| if (settings && Object.keys(settings).length > 0) { | |
| merged = mergeWith(merged, settings, settingsMergeCustomizer) | |
| found = true | |
| } | |
| const dropInDir = getManagedSettingsDropInDir() | |
| try { | |
| const entries = getFsImplementation() | |
| .readdirSync(dropInDir) | |
| .filter( | |
| d => | |
| (d.isFile() || d.isSymbolicLink()) && | |
| d.name.endsWith('.json') && | |
| !d.name.startsWith('.'), | |
| ) | |
| .map(d => d.name) | |
| .sort() | |
| for (const name of entries) { | |
| const { settings, errors: fileErrors } = parseSettingsFile( | |
| join(dropInDir, name), | |
| ) | |
| errors.push(...fileErrors) | |
| if (settings && Object.keys(settings).length > 0) { | |
| merged = mergeWith(merged, settings, settingsMergeCustomizer) | |
| found = true | |
| } | |
| } | |
| } catch (e) { | |
| const code = getErrnoCode(e) | |
| if (code !== 'ENOENT' && code !== 'ENOTDIR') { | |
| logError(e) | |
| } | |
| } | |
| return { settings: found ? merged : null, errors } | |
| } | |
| /** | |
| * Check which file-based managed settings sources are present. | |
| * Used by /status to show "(file)", "(drop-ins)", or "(file + drop-ins)". | |
| */ | |
| export function getManagedFileSettingsPresence(): { | |
| hasBase: boolean | |
| hasDropIns: boolean | |
| } { | |
| const { settings: base } = parseSettingsFile(getManagedSettingsFilePath()) | |
| const hasBase = !!base && Object.keys(base).length > 0 | |
| let hasDropIns = false | |
| const dropInDir = getManagedSettingsDropInDir() | |
| try { | |
| hasDropIns = getFsImplementation() | |
| .readdirSync(dropInDir) | |
| .some( | |
| d => | |
| (d.isFile() || d.isSymbolicLink()) && | |
| d.name.endsWith('.json') && | |
| !d.name.startsWith('.'), | |
| ) | |
| } catch { | |
| // dir doesn't exist | |
| } | |
| return { hasBase, hasDropIns } | |
| } | |
| /** | |
| * Handles file system errors appropriately | |
| * @param error The error to handle | |
| * @param path The file path that caused the error | |
| */ | |
| function handleFileSystemError(error: unknown, path: string): void { | |
| if ( | |
| typeof error === 'object' && | |
| error && | |
| 'code' in error && | |
| error.code === 'ENOENT' | |
| ) { | |
| logForDebugging( | |
| `Broken symlink or missing file encountered for settings.json at path: ${path}`, | |
| ) | |
| } else { | |
| logError(error) | |
| } | |
| } | |
| /** | |
| * Parses a settings file into a structured format | |
| * @param path The path to the permissions file | |
| * @param source The source of the settings (optional, for error reporting) | |
| * @returns Parsed settings data and validation errors | |
| */ | |
| export function parseSettingsFile(path: string): { | |
| settings: SettingsJson | null | |
| errors: ValidationError[] | |
| } { | |
| const cached = getCachedParsedFile(path) | |
| if (cached) { | |
| // Clone so callers (e.g. mergeWith in getSettingsForSourceUncached, | |
| // updateSettingsForSource) can't mutate the cached entry. | |
| return { | |
| settings: cached.settings ? clone(cached.settings) : null, | |
| errors: cached.errors, | |
| } | |
| } | |
| const result = parseSettingsFileUncached(path) | |
| setCachedParsedFile(path, result) | |
| // Clone the first return too — the caller may mutate before | |
| // another caller reads the same cache entry. | |
| return { | |
| settings: result.settings ? clone(result.settings) : null, | |
| errors: result.errors, | |
| } | |
| } | |
| function parseSettingsFileUncached(path: string): { | |
| settings: SettingsJson | null | |
| errors: ValidationError[] | |
| } { | |
| try { | |
| const { resolvedPath } = safeResolvePath(getFsImplementation(), path) | |
| const content = readFileSync(resolvedPath) | |
| if (content.trim() === '') { | |
| return { settings: {}, errors: [] } | |
| } | |
| const data = safeParseJSON(content, false) | |
| // Filter invalid permission rules before schema validation so one bad | |
| // rule doesn't cause the entire settings file to be rejected. | |
| const ruleWarnings = filterInvalidPermissionRules(data, path) | |
| const result = SettingsSchema().safeParse(data) | |
| if (!result.success) { | |
| const errors = formatZodError(result.error, path) | |
| return { settings: null, errors: [...ruleWarnings, ...errors] } | |
| } | |
| return { settings: result.data, errors: ruleWarnings } | |
| } catch (error) { | |
| handleFileSystemError(error, path) | |
| return { settings: null, errors: [] } | |
| } | |
| } | |
| /** | |
| * Get the absolute path to the associated file root for a given settings source | |
| * (e.g. for $PROJ_DIR/.claude/settings.json, returns $PROJ_DIR) | |
| * @param source The source of the settings | |
| * @returns The root path of the settings file | |
| */ | |
| export function getSettingsRootPathForSource(source: SettingSource): string { | |
| switch (source) { | |
| case 'userSettings': | |
| return resolve(getClaudeConfigHomeDir()) | |
| case 'policySettings': | |
| case 'projectSettings': | |
| case 'localSettings': { | |
| return resolve(getOriginalCwd()) | |
| } | |
| case 'flagSettings': { | |
| const path = getFlagSettingsPath() | |
| return path ? dirname(resolve(path)) : resolve(getOriginalCwd()) | |
| } | |
| } | |
| } | |
| /** | |
| * Get the user settings filename based on cowork mode. | |
| * Returns 'cowork_settings.json' when in cowork mode, 'settings.json' otherwise. | |
| * | |
| * Priority: | |
| * 1. Session state (set by CLI flag --cowork) | |
| * 2. Environment variable CLAUDE_CODE_USE_COWORK_PLUGINS | |
| * 3. Default: 'settings.json' | |
| */ | |
| function getUserSettingsFilePath(): string { | |
| if ( | |
| getUseCoworkPlugins() || | |
| isEnvTruthy(process.env.CLAUDE_CODE_USE_COWORK_PLUGINS) | |
| ) { | |
| return 'cowork_settings.json' | |
| } | |
| return 'settings.json' | |
| } | |
| export function getSettingsFilePathForSource( | |
| source: SettingSource, | |
| ): string | undefined { | |
| switch (source) { | |
| case 'userSettings': | |
| return join( | |
| getSettingsRootPathForSource(source), | |
| getUserSettingsFilePath(), | |
| ) | |
| case 'projectSettings': | |
| case 'localSettings': { | |
| return join( | |
| getSettingsRootPathForSource(source), | |
| getRelativeSettingsFilePathForSource(source), | |
| ) | |
| } | |
| case 'policySettings': | |
| return getManagedSettingsFilePath() | |
| case 'flagSettings': { | |
| return getFlagSettingsPath() | |
| } | |
| } | |
| } | |
| export function getRelativeSettingsFilePathForSource( | |
| source: 'projectSettings' | 'localSettings', | |
| ): string { | |
| switch (source) { | |
| case 'projectSettings': | |
| return join('.claude', 'settings.json') | |
| case 'localSettings': | |
| return join('.claude', 'settings.local.json') | |
| } | |
| } | |
| export function getSettingsForSource( | |
| source: SettingSource, | |
| ): SettingsJson | null { | |
| const cached = getCachedSettingsForSource(source) | |
| if (cached !== undefined) return cached | |
| const result = getSettingsForSourceUncached(source) | |
| setCachedSettingsForSource(source, result) | |
| return result | |
| } | |
| function getSettingsForSourceUncached( | |
| source: SettingSource, | |
| ): SettingsJson | null { | |
| // For policySettings: first source wins (remote > HKLM/plist > file > HKCU) | |
| if (source === 'policySettings') { | |
| const remoteSettings = getRemoteManagedSettingsSyncFromCache() | |
| if (remoteSettings && Object.keys(remoteSettings).length > 0) { | |
| return remoteSettings | |
| } | |
| const mdmResult = getMdmSettings() | |
| if (Object.keys(mdmResult.settings).length > 0) { | |
| return mdmResult.settings | |
| } | |
| const { settings: fileSettings } = loadManagedFileSettings() | |
| if (fileSettings) { | |
| return fileSettings | |
| } | |
| const hkcu = getHkcuSettings() | |
| if (Object.keys(hkcu.settings).length > 0) { | |
| return hkcu.settings | |
| } | |
| return null | |
| } | |
| const settingsFilePath = getSettingsFilePathForSource(source) | |
| const { settings: fileSettings } = settingsFilePath | |
| ? parseSettingsFile(settingsFilePath) | |
| : { settings: null } | |
| // For flagSettings, merge in any inline settings set via the SDK | |
| if (source === 'flagSettings') { | |
| const inlineSettings = getFlagSettingsInline() | |
| if (inlineSettings) { | |
| const parsed = SettingsSchema().safeParse(inlineSettings) | |
| if (parsed.success) { | |
| return mergeWith( | |
| fileSettings || {}, | |
| parsed.data, | |
| settingsMergeCustomizer, | |
| ) as SettingsJson | |
| } | |
| } | |
| } | |
| return fileSettings | |
| } | |
| /** | |
| * Get the origin of the highest-priority active policy settings source. | |
| * Uses "first source wins" — returns the first source that has content. | |
| * Priority: remote > plist/hklm > file (managed-settings.json) > hkcu | |
| */ | |
| export function getPolicySettingsOrigin(): | |
| | 'remote' | |
| | 'plist' | |
| | 'hklm' | |
| | 'file' | |
| | 'hkcu' | |
| | null { | |
| // 1. Remote (highest) | |
| const remoteSettings = getRemoteManagedSettingsSyncFromCache() | |
| if (remoteSettings && Object.keys(remoteSettings).length > 0) { | |
| return 'remote' | |
| } | |
| // 2. Admin-only MDM (HKLM / macOS plist) | |
| const mdmResult = getMdmSettings() | |
| if (Object.keys(mdmResult.settings).length > 0) { | |
| return getPlatform() === 'macos' ? 'plist' : 'hklm' | |
| } | |
| // 3. managed-settings.json + managed-settings.d/ (file-based, requires admin) | |
| const { settings: fileSettings } = loadManagedFileSettings() | |
| if (fileSettings) { | |
| return 'file' | |
| } | |
| // 4. HKCU (lowest — user-writable) | |
| const hkcu = getHkcuSettings() | |
| if (Object.keys(hkcu.settings).length > 0) { | |
| return 'hkcu' | |
| } | |
| return null | |
| } | |
| /** | |
| * Merges `settings` into the existing settings for `source` using lodash mergeWith. | |
| * | |
| * To delete a key from a record field (e.g. enabledPlugins, extraKnownMarketplaces), | |
| * set it to `undefined` — do NOT use `delete`. mergeWith only detects deletion when | |
| * the key is present with an explicit `undefined` value. | |
| */ | |
| export function updateSettingsForSource( | |
| source: EditableSettingSource, | |
| settings: SettingsJson, | |
| ): { error: Error | null } { | |
| if ( | |
| (source as unknown) === 'policySettings' || | |
| (source as unknown) === 'flagSettings' | |
| ) { | |
| return { error: null } | |
| } | |
| // Create the folder if needed | |
| const filePath = getSettingsFilePathForSource(source) | |
| if (!filePath) { | |
| return { error: null } | |
| } | |
| try { | |
| getFsImplementation().mkdirSync(dirname(filePath)) | |
| // Try to get existing settings with validation. Bypass the per-source | |
| // cache — mergeWith below mutates its target (including nested refs), | |
| // and mutating the cached object would leak unpersisted state if the | |
| // write fails before resetSettingsCache(). | |
| let existingSettings = getSettingsForSourceUncached(source) | |
| // If validation failed, check if file exists with a JSON syntax error | |
| if (!existingSettings) { | |
| let content: string | null = null | |
| try { | |
| content = readFileSync(filePath) | |
| } catch (e) { | |
| if (!isENOENT(e)) { | |
| throw e | |
| } | |
| // File doesn't exist — fall through to merge with empty settings | |
| } | |
| if (content !== null) { | |
| const rawData = safeParseJSON(content) | |
| if (rawData === null) { | |
| // JSON syntax error - return validation error instead of overwriting | |
| // safeParseJSON will already log the error, so we'll just return the error here | |
| return { | |
| error: new Error( | |
| `Invalid JSON syntax in settings file at ${filePath}`, | |
| ), | |
| } | |
| } | |
| if (rawData && typeof rawData === 'object') { | |
| existingSettings = rawData as SettingsJson | |
| logForDebugging( | |
| `Using raw settings from ${filePath} due to validation failure`, | |
| ) | |
| } | |
| } | |
| } | |
| const updatedSettings = mergeWith( | |
| existingSettings || {}, | |
| settings, | |
| ( | |
| _objValue: unknown, | |
| srcValue: unknown, | |
| key: string | number | symbol, | |
| object: Record<string | number | symbol, unknown>, | |
| ) => { | |
| // Handle undefined as deletion | |
| if (srcValue === undefined && object && typeof key === 'string') { | |
| delete object[key] | |
| return undefined | |
| } | |
| // For arrays, always replace with the provided array | |
| // This puts the responsibility on the caller to compute the desired final state | |
| if (Array.isArray(srcValue)) { | |
| return srcValue | |
| } | |
| // For non-arrays, let lodash handle the default merge behavior | |
| return undefined | |
| }, | |
| ) | |
| // Mark this as an internal write before writing the file | |
| markInternalWrite(filePath) | |
| writeFileSyncAndFlush_DEPRECATED( | |
| filePath, | |
| jsonStringify(updatedSettings, null, 2) + '\n', | |
| ) | |
| // Invalidate the session cache since settings have been updated | |
| resetSettingsCache() | |
| if (source === 'localSettings') { | |
| // Okay to add to gitignore async without awaiting | |
| void addFileGlobRuleToGitignore( | |
| getRelativeSettingsFilePathForSource('localSettings'), | |
| getOriginalCwd(), | |
| ) | |
| } | |
| } catch (e) { | |
| const error = new Error( | |
| `Failed to read raw settings from ${filePath}: ${e}`, | |
| ) | |
| logError(error) | |
| return { error } | |
| } | |
| return { error: null } | |
| } | |
| /** | |
| * Custom merge function for arrays - concatenate and deduplicate | |
| */ | |
| function mergeArrays<T>(targetArray: T[], sourceArray: T[]): T[] { | |
| return uniq([...targetArray, ...sourceArray]) | |
| } | |
| /** | |
| * Custom merge function for lodash mergeWith when merging settings. | |
| * Arrays are concatenated and deduplicated; other values use default lodash merge behavior. | |
| * Exported for testing. | |
| */ | |
| export function settingsMergeCustomizer( | |
| objValue: unknown, | |
| srcValue: unknown, | |
| ): unknown { | |
| if (Array.isArray(objValue) && Array.isArray(srcValue)) { | |
| return mergeArrays(objValue, srcValue) | |
| } | |
| // Return undefined to let lodash handle default merge behavior | |
| return undefined | |
| } | |
| /** | |
| * Get a list of setting keys from managed settings for logging purposes. | |
| * For certain nested settings (permissions, sandbox, hooks), expands to show | |
| * one level of nesting (e.g., "permissions.allow"). For other settings, | |
| * returns only the top-level key. | |
| * | |
| * @param settings The settings object to extract keys from | |
| * @returns Sorted array of key paths | |
| */ | |
| export function getManagedSettingsKeysForLogging( | |
| settings: SettingsJson, | |
| ): string[] { | |
| // Use .strip() to get only valid schema keys | |
| const validSettings = SettingsSchema().strip().parse(settings) as Record< | |
| string, | |
| unknown | |
| > | |
| const keysToExpand = ['permissions', 'sandbox', 'hooks'] | |
| const allKeys: string[] = [] | |
| // Define valid nested keys for each nested setting we expand | |
| const validNestedKeys: Record<string, Set<string>> = { | |
| permissions: new Set([ | |
| 'allow', | |
| 'deny', | |
| 'ask', | |
| 'defaultMode', | |
| 'disableBypassPermissionsMode', | |
| ...(feature('TRANSCRIPT_CLASSIFIER') ? ['disableAutoMode'] : []), | |
| 'additionalDirectories', | |
| ]), | |
| sandbox: new Set([ | |
| 'enabled', | |
| 'failIfUnavailable', | |
| 'allowUnsandboxedCommands', | |
| 'network', | |
| 'filesystem', | |
| 'ignoreViolations', | |
| 'excludedCommands', | |
| 'autoAllowBashIfSandboxed', | |
| 'enableWeakerNestedSandbox', | |
| 'enableWeakerNetworkIsolation', | |
| 'ripgrep', | |
| ]), | |
| // For hooks, we use z.record with enum keys, so we validate separately | |
| hooks: new Set([ | |
| 'PreToolUse', | |
| 'PostToolUse', | |
| 'Notification', | |
| 'UserPromptSubmit', | |
| 'SessionStart', | |
| 'SessionEnd', | |
| 'Stop', | |
| 'SubagentStop', | |
| 'PreCompact', | |
| 'PostCompact', | |
| 'TeammateIdle', | |
| 'TaskCreated', | |
| 'TaskCompleted', | |
| ]), | |
| } | |
| for (const key of Object.keys(validSettings)) { | |
| if ( | |
| keysToExpand.includes(key) && | |
| validSettings[key] && | |
| typeof validSettings[key] === 'object' | |
| ) { | |
| // Expand nested keys for these special settings (one level deep only) | |
| const nestedObj = validSettings[key] as Record<string, unknown> | |
| const validKeys = validNestedKeys[key] | |
| if (validKeys) { | |
| for (const nestedKey of Object.keys(nestedObj)) { | |
| // Only include known valid nested keys | |
| if (validKeys.has(nestedKey)) { | |
| allKeys.push(`${key}.${nestedKey}`) | |
| } | |
| } | |
| } | |
| } else { | |
| // For other settings, just use the top-level key | |
| allKeys.push(key) | |
| } | |
| } | |
| return allKeys.sort() | |
| } | |
| // Flag to prevent infinite recursion when loading settings | |
| let isLoadingSettings = false | |
| /** | |
| * Load settings from disk without using cache | |
| * This is the original implementation that actually reads from files | |
| */ | |
| function loadSettingsFromDisk(): SettingsWithErrors { | |
| // Prevent recursive calls to loadSettingsFromDisk | |
| if (isLoadingSettings) { | |
| return { settings: {}, errors: [] } | |
| } | |
| const startTime = Date.now() | |
| profileCheckpoint('loadSettingsFromDisk_start') | |
| logForDiagnosticsNoPII('info', 'settings_load_started') | |
| isLoadingSettings = true | |
| try { | |
| // Start with plugin settings as the lowest priority base. | |
| // All file-based sources (user, project, local, flag, policy) override these. | |
| // Plugin settings only contain allowlisted keys (e.g., agent) that are valid SettingsJson fields. | |
| const pluginSettings = getPluginSettingsBase() | |
| let mergedSettings: SettingsJson = {} | |
| if (pluginSettings) { | |
| mergedSettings = mergeWith( | |
| mergedSettings, | |
| pluginSettings, | |
| settingsMergeCustomizer, | |
| ) | |
| } | |
| const allErrors: ValidationError[] = [] | |
| const seenErrors = new Set<string>() | |
| const seenFiles = new Set<string>() | |
| // Merge settings from each source in priority order with deep merging | |
| for (const source of getEnabledSettingSources()) { | |
| // policySettings: "first source wins" — use the highest-priority source | |
| // that has content. Priority: remote > HKLM/plist > managed-settings.json > HKCU | |
| if (source === 'policySettings') { | |
| let policySettings: SettingsJson | null = null | |
| const policyErrors: ValidationError[] = [] | |
| // 1. Remote (highest priority) | |
| const remoteSettings = getRemoteManagedSettingsSyncFromCache() | |
| if (remoteSettings && Object.keys(remoteSettings).length > 0) { | |
| const result = SettingsSchema().safeParse(remoteSettings) | |
| if (result.success) { | |
| policySettings = result.data | |
| } else { | |
| // Remote exists but is invalid — surface errors even as we fall through | |
| policyErrors.push( | |
| ...formatZodError(result.error, 'remote managed settings'), | |
| ) | |
| } | |
| } | |
| // 2. Admin-only MDM (HKLM / macOS plist) | |
| if (!policySettings) { | |
| const mdmResult = getMdmSettings() | |
| if (Object.keys(mdmResult.settings).length > 0) { | |
| policySettings = mdmResult.settings | |
| } | |
| policyErrors.push(...mdmResult.errors) | |
| } | |
| // 3. managed-settings.json + managed-settings.d/ (file-based, requires admin) | |
| if (!policySettings) { | |
| const { settings, errors } = loadManagedFileSettings() | |
| if (settings) { | |
| policySettings = settings | |
| } | |
| policyErrors.push(...errors) | |
| } | |
| // 4. HKCU (lowest — user-writable, only if nothing above exists) | |
| if (!policySettings) { | |
| const hkcu = getHkcuSettings() | |
| if (Object.keys(hkcu.settings).length > 0) { | |
| policySettings = hkcu.settings | |
| } | |
| policyErrors.push(...hkcu.errors) | |
| } | |
| // Merge the winning policy source into the settings chain | |
| if (policySettings) { | |
| mergedSettings = mergeWith( | |
| mergedSettings, | |
| policySettings, | |
| settingsMergeCustomizer, | |
| ) | |
| } | |
| for (const error of policyErrors) { | |
| const errorKey = `${error.file}:${error.path}:${error.message}` | |
| if (!seenErrors.has(errorKey)) { | |
| seenErrors.add(errorKey) | |
| allErrors.push(error) | |
| } | |
| } | |
| continue | |
| } | |
| const filePath = getSettingsFilePathForSource(source) | |
| if (filePath) { | |
| const resolvedPath = resolve(filePath) | |
| // Skip if we've already loaded this file from another source | |
| if (!seenFiles.has(resolvedPath)) { | |
| seenFiles.add(resolvedPath) | |
| const { settings, errors } = parseSettingsFile(filePath) | |
| // Add unique errors (deduplication) | |
| for (const error of errors) { | |
| const errorKey = `${error.file}:${error.path}:${error.message}` | |
| if (!seenErrors.has(errorKey)) { | |
| seenErrors.add(errorKey) | |
| allErrors.push(error) | |
| } | |
| } | |
| if (settings) { | |
| mergedSettings = mergeWith( | |
| mergedSettings, | |
| settings, | |
| settingsMergeCustomizer, | |
| ) | |
| } | |
| } | |
| } | |
| // For flagSettings, also merge any inline settings set via the SDK | |
| if (source === 'flagSettings') { | |
| const inlineSettings = getFlagSettingsInline() | |
| if (inlineSettings) { | |
| const parsed = SettingsSchema().safeParse(inlineSettings) | |
| if (parsed.success) { | |
| mergedSettings = mergeWith( | |
| mergedSettings, | |
| parsed.data, | |
| settingsMergeCustomizer, | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| logForDiagnosticsNoPII('info', 'settings_load_completed', { | |
| duration_ms: Date.now() - startTime, | |
| source_count: seenFiles.size, | |
| error_count: allErrors.length, | |
| }) | |
| return { settings: mergedSettings, errors: allErrors } | |
| } finally { | |
| isLoadingSettings = false | |
| } | |
| } | |
| /** | |
| * Get merged settings from all sources in priority order | |
| * Settings are merged from lowest to highest priority: | |
| * userSettings -> projectSettings -> localSettings -> policySettings | |
| * | |
| * This function returns a snapshot of settings at the time of call. | |
| * For React components, prefer using useSettings() hook for reactive updates | |
| * when settings change on disk. | |
| * | |
| * Uses session-level caching to avoid repeated file I/O. | |
| * Cache is invalidated when settings files change via resetSettingsCache(). | |
| * | |
| * @returns Merged settings from all available sources (always returns at least empty object) | |
| */ | |
| export function getInitialSettings(): SettingsJson { | |
| const { settings } = getSettingsWithErrors() | |
| return settings || {} | |
| } | |
| /** | |
| * @deprecated Use getInitialSettings() instead. This alias exists for backwards compatibility. | |
| */ | |
| export const getSettings_DEPRECATED = getInitialSettings | |
| export type SettingsWithSources = { | |
| effective: SettingsJson | |
| /** Ordered low-to-high priority — later entries override earlier ones. */ | |
| sources: Array<{ source: SettingSource; settings: SettingsJson }> | |
| } | |
| /** | |
| * Get the effective merged settings alongside the raw per-source settings, | |
| * in merge-priority order. Only includes sources that are enabled and have | |
| * non-empty content. | |
| * | |
| * Always reads fresh from disk — resets the session cache so that `effective` | |
| * and `sources` are consistent even if the change detector hasn't fired yet. | |
| */ | |
| export function getSettingsWithSources(): SettingsWithSources { | |
| // Reset both caches so getSettingsForSource (per-source cache) and | |
| // getInitialSettings (session cache) agree on the current disk state. | |
| resetSettingsCache() | |
| const sources: SettingsWithSources['sources'] = [] | |
| for (const source of getEnabledSettingSources()) { | |
| const settings = getSettingsForSource(source) | |
| if (settings && Object.keys(settings).length > 0) { | |
| sources.push({ source, settings }) | |
| } | |
| } | |
| return { effective: getInitialSettings(), sources } | |
| } | |
| /** | |
| * Get merged settings and validation errors from all sources | |
| * This function now uses session-level caching to avoid repeated file I/O. | |
| * Settings changes require Claude Code restart, so cache is valid for entire session. | |
| * @returns Merged settings and all validation errors encountered | |
| */ | |
| export function getSettingsWithErrors(): SettingsWithErrors { | |
| // Use cached result if available | |
| const cached = getSessionSettingsCache() | |
| if (cached !== null) { | |
| return cached | |
| } | |
| // Load from disk and cache the result | |
| const result = loadSettingsFromDisk() | |
| profileCheckpoint('loadSettingsFromDisk_end') | |
| setSessionSettingsCache(result) | |
| return result | |
| } | |
| /** | |
| * Check if any raw settings file contains a specific key, regardless of validation. | |
| * This is useful for detecting user intent even when settings validation fails. | |
| * For example, if a user set cleanupPeriodDays but has validation errors elsewhere, | |
| * we can detect they explicitly configured cleanup and skip cleanup rather than | |
| * falling back to defaults. | |
| */ | |
| /** | |
| * Returns true if any trusted settings source has accepted the bypass | |
| * permissions mode dialog. projectSettings is intentionally excluded — | |
| * a malicious project could otherwise auto-bypass the dialog (RCE risk). | |
| */ | |
| export function hasSkipDangerousModePermissionPrompt(): boolean { | |
| return !!( | |
| getSettingsForSource('userSettings')?.skipDangerousModePermissionPrompt || | |
| getSettingsForSource('localSettings')?.skipDangerousModePermissionPrompt || | |
| getSettingsForSource('flagSettings')?.skipDangerousModePermissionPrompt || | |
| getSettingsForSource('policySettings')?.skipDangerousModePermissionPrompt | |
| ) | |
| } | |
| /** | |
| * Returns true if any trusted settings source has accepted the auto | |
| * mode opt-in dialog. projectSettings is intentionally excluded — | |
| * a malicious project could otherwise auto-bypass the dialog (RCE risk). | |
| */ | |
| export function hasAutoModeOptIn(): boolean { | |
| if (feature('TRANSCRIPT_CLASSIFIER')) { | |
| const user = getSettingsForSource('userSettings')?.skipAutoPermissionPrompt | |
| const local = | |
| getSettingsForSource('localSettings')?.skipAutoPermissionPrompt | |
| const flag = getSettingsForSource('flagSettings')?.skipAutoPermissionPrompt | |
| const policy = | |
| getSettingsForSource('policySettings')?.skipAutoPermissionPrompt | |
| const result = !!(user || local || flag || policy) | |
| logForDebugging( | |
| `[auto-mode] hasAutoModeOptIn=${result} skipAutoPermissionPrompt: user=${user} local=${local} flag=${flag} policy=${policy}`, | |
| ) | |
| return result | |
| } | |
| return false | |
| } | |
| /** | |
| * Returns whether plan mode should use auto mode semantics. Default true | |
| * (opt-out). Returns false if any trusted source explicitly sets false. | |
| * projectSettings is excluded so a malicious project can't control this. | |
| */ | |
| export function getUseAutoModeDuringPlan(): boolean { | |
| if (feature('TRANSCRIPT_CLASSIFIER')) { | |
| return ( | |
| getSettingsForSource('policySettings')?.useAutoModeDuringPlan !== false && | |
| getSettingsForSource('flagSettings')?.useAutoModeDuringPlan !== false && | |
| getSettingsForSource('userSettings')?.useAutoModeDuringPlan !== false && | |
| getSettingsForSource('localSettings')?.useAutoModeDuringPlan !== false | |
| ) | |
| } | |
| return true | |
| } | |
| /** | |
| * Returns the merged autoMode config from trusted settings sources. | |
| * Only available when TRANSCRIPT_CLASSIFIER is active; returns undefined otherwise. | |
| * projectSettings is intentionally excluded — a malicious project could | |
| * otherwise inject classifier allow/deny rules (RCE risk). | |
| */ | |
| export function getAutoModeConfig(): | |
| | { allow?: string[]; soft_deny?: string[]; environment?: string[] } | |
| | undefined { | |
| if (feature('TRANSCRIPT_CLASSIFIER')) { | |
| const schema = z.object({ | |
| allow: z.array(z.string()).optional(), | |
| soft_deny: z.array(z.string()).optional(), | |
| deny: z.array(z.string()).optional(), | |
| environment: z.array(z.string()).optional(), | |
| }) | |
| const allow: string[] = [] | |
| const soft_deny: string[] = [] | |
| const environment: string[] = [] | |
| for (const source of [ | |
| 'userSettings', | |
| 'localSettings', | |
| 'flagSettings', | |
| 'policySettings', | |
| ] as const) { | |
| const settings = getSettingsForSource(source) | |
| if (!settings) continue | |
| const result = schema.safeParse( | |
| (settings as Record<string, unknown>).autoMode, | |
| ) | |
| if (result.success) { | |
| if (result.data.allow) allow.push(...result.data.allow) | |
| if (result.data.soft_deny) soft_deny.push(...result.data.soft_deny) | |
| if (process.env.USER_TYPE === 'ant') { | |
| if (result.data.deny) soft_deny.push(...result.data.deny) | |
| } | |
| if (result.data.environment) | |
| environment.push(...result.data.environment) | |
| } | |
| } | |
| if (allow.length > 0 || soft_deny.length > 0 || environment.length > 0) { | |
| return { | |
| ...(allow.length > 0 && { allow }), | |
| ...(soft_deny.length > 0 && { soft_deny }), | |
| ...(environment.length > 0 && { environment }), | |
| } | |
| } | |
| } | |
| return undefined | |
| } | |
| export function rawSettingsContainsKey(key: string): boolean { | |
| for (const source of getEnabledSettingSources()) { | |
| // Skip policySettings - we only care about user-configured settings | |
| if (source === 'policySettings') { | |
| continue | |
| } | |
| const filePath = getSettingsFilePathForSource(source) | |
| if (!filePath) { | |
| continue | |
| } | |
| try { | |
| const { resolvedPath } = safeResolvePath(getFsImplementation(), filePath) | |
| const content = readFileSync(resolvedPath) | |
| if (!content.trim()) { | |
| continue | |
| } | |
| const rawData = safeParseJSON(content, false) | |
| if (rawData && typeof rawData === 'object' && key in rawData) { | |
| return true | |
| } | |
| } catch (error) { | |
| // File not found is expected - not all settings files exist | |
| // Other errors (permissions, I/O) should be tracked | |
| handleFileSystemError(error, filePath) | |
| } | |
| } | |
| return false | |
| } | |