π File detail
utils/settings/settings.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes loadManagedFileSettings, getManagedFileSettingsPresence, parseSettingsFile, getSettingsRootPathForSource, and getSettingsFilePathForSource (and more) β mainly functions, hooks, or classes. Dependencies touch bun:bundle, lodash-es, Node path helpers, and schema validation. It composes internal code from bootstrap, services, array, debug, and diagLogs (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import { feature } from 'bun:bundle' import mergeWith from 'lodash-es/mergeWith.js' import { dirname, join, resolve } from 'path' import { z } from 'zod/v4' import {
π€ Exports (heuristic)
loadManagedFileSettingsgetManagedFileSettingsPresenceparseSettingsFilegetSettingsRootPathForSourcegetSettingsFilePathForSourcegetRelativeSettingsFilePathForSourcegetSettingsForSourcegetPolicySettingsOriginupdateSettingsForSourcesettingsMergeCustomizergetManagedSettingsKeysForLogginggetInitialSettingsgetSettings_DEPRECATEDSettingsWithSourcesgetSettingsWithSourcesgetSettingsWithErrorshasSkipDangerousModePermissionPrompthasAutoModeOptIngetUseAutoModeDuringPlangetAutoModeConfigrawSettingsContainsKey
π External import roots
Package roots from from "β¦" (relative paths omitted).
bun:bundlelodash-espathzod
π₯οΈ Source preview
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
}