settings.ts
utils/settings/mdm/settings.ts
No strong subsystem tag
317
Lines
10716
Bytes
9
Exports
12
Imports
10
Keywords
What this is
This page documents one file from the repository and includes its full source so you can read it without leaving the docs site.
Beginner explanation
This file is one piece of the larger system. Its name, directory, imports, and exports show where it fits. Start by reading the exports and related files first.
How it is used
Start from the exports list and related files. Those are the easiest clues for where this file fits into the system.
Expert explanation
Architecturally, this file intersects with general runtime concerns. It contains 317 lines, 12 detected imports, and 9 detected exports.
Important relationships
Detected exports
startMdmSettingsLoadensureMdmSettingsLoadedgetMdmSettingsgetHkcuSettingsclearMdmSettingsCachesetMdmSettingsCacherefreshMdmSettingsparseCommandOutputAsSettingsparseRegQueryStdout
Keywords
settingshkcudatamdmresultjsonobjectempty_resultresulterrorsread
Detected imports
path../../debug.js../../diagLogs.js../../fileRead.js../../fsOperations.js../../json.js../../startupProfiler.js../managedPath.js../types.js../validation.js./constants.js./rawRead.js
Source notes
This page embeds the full file contents. Small or leaf files are still indexed honestly instead of being over-explained.
Full source
/**
* MDM (Mobile Device Management) profile enforcement for Claude Code managed settings.
*
* Reads enterprise settings from OS-level MDM configuration:
* - macOS: `com.anthropic.claudecode` preference domain
* (MDM profiles at /Library/Managed Preferences/ only — not user-writable ~/Library/Preferences/)
* - Windows: `HKLM\SOFTWARE\Policies\ClaudeCode` (admin-only)
* and `HKCU\SOFTWARE\Policies\ClaudeCode` (user-writable, lowest priority)
* - Linux: No MDM equivalent (uses /etc/claude-code/managed-settings.json instead)
*
* Policy settings use "first source wins" — the highest-priority source that exists
* provides all policy settings. Priority (highest to lowest):
* remote → HKLM/plist → managed-settings.json → HKCU
*
* Architecture:
* constants.ts — shared constants and plist path builder (zero heavy imports)
* rawRead.ts — subprocess I/O only (zero heavy imports, fires at main.tsx evaluation)
* settings.ts — parsing, caching, first-source-wins logic (this file)
*/
import { join } from 'path'
import { logForDebugging } from '../../debug.js'
import { logForDiagnosticsNoPII } from '../../diagLogs.js'
import { readFileSync } from '../../fileRead.js'
import { getFsImplementation } from '../../fsOperations.js'
import { safeParseJSON } from '../../json.js'
import { profileCheckpoint } from '../../startupProfiler.js'
import {
getManagedFilePath,
getManagedSettingsDropInDir,
} from '../managedPath.js'
import { type SettingsJson, SettingsSchema } from '../types.js'
import {
filterInvalidPermissionRules,
formatZodError,
type ValidationError,
} from '../validation.js'
import {
WINDOWS_REGISTRY_KEY_PATH_HKCU,
WINDOWS_REGISTRY_KEY_PATH_HKLM,
WINDOWS_REGISTRY_VALUE_NAME,
} from './constants.js'
import {
fireRawRead,
getMdmRawReadPromise,
type RawReadResult,
} from './rawRead.js'
// ---------------------------------------------------------------------------
// Types and cache
// ---------------------------------------------------------------------------
type MdmResult = { settings: SettingsJson; errors: ValidationError[] }
const EMPTY_RESULT: MdmResult = Object.freeze({ settings: {}, errors: [] })
let mdmCache: MdmResult | null = null
let hkcuCache: MdmResult | null = null
let mdmLoadPromise: Promise<void> | null = null
// ---------------------------------------------------------------------------
// Startup load — fires early, awaited before first settings read
// ---------------------------------------------------------------------------
/**
* Kick off async MDM/HKCU reads. Call this as early as possible in
* startup so the subprocess runs in parallel with module loading.
*/
export function startMdmSettingsLoad(): void {
if (mdmLoadPromise) return
mdmLoadPromise = (async () => {
profileCheckpoint('mdm_load_start')
const startTime = Date.now()
// Use the startup raw read if cli.tsx fired it, otherwise fire a fresh one.
// Both paths produce the same RawReadResult; consumeRawReadResult parses it.
const rawPromise = getMdmRawReadPromise() ?? fireRawRead()
const { mdm, hkcu } = consumeRawReadResult(await rawPromise)
mdmCache = mdm
hkcuCache = hkcu
profileCheckpoint('mdm_load_end')
const duration = Date.now() - startTime
logForDebugging(`MDM settings load completed in ${duration}ms`)
if (Object.keys(mdm.settings).length > 0) {
logForDebugging(
`MDM settings found: ${Object.keys(mdm.settings).join(', ')}`,
)
try {
logForDiagnosticsNoPII('info', 'mdm_settings_loaded', {
duration_ms: duration,
key_count: Object.keys(mdm.settings).length,
error_count: mdm.errors.length,
})
} catch {
// Diagnostic logging is best-effort
}
}
})()
}
/**
* Await the in-flight MDM load. Call this before the first settings read.
* If startMdmSettingsLoad() was called early enough, this resolves immediately.
*/
export async function ensureMdmSettingsLoaded(): Promise<void> {
if (!mdmLoadPromise) {
startMdmSettingsLoad()
}
await mdmLoadPromise
}
// ---------------------------------------------------------------------------
// Sync cache readers — used by the settings pipeline (loadSettingsFromDisk)
// ---------------------------------------------------------------------------
/**
* Read admin-controlled MDM settings from the session cache.
*
* Returns settings from admin-only sources:
* - macOS: /Library/Managed Preferences/ (requires root)
* - Windows: HKLM registry (requires admin)
*
* Does NOT include HKCU (user-writable) — use getHkcuSettings() for that.
*/
export function getMdmSettings(): MdmResult {
return mdmCache ?? EMPTY_RESULT
}
/**
* Read HKCU registry settings (user-writable, lowest policy priority).
* Only relevant on Windows — returns empty on other platforms.
*/
export function getHkcuSettings(): MdmResult {
return hkcuCache ?? EMPTY_RESULT
}
// ---------------------------------------------------------------------------
// Cache management
// ---------------------------------------------------------------------------
/**
* Clear the MDM and HKCU settings caches, forcing a fresh read on next load.
*/
export function clearMdmSettingsCache(): void {
mdmCache = null
hkcuCache = null
mdmLoadPromise = null
}
/**
* Update the session caches directly. Used by the change detector poll.
*/
export function setMdmSettingsCache(mdm: MdmResult, hkcu: MdmResult): void {
mdmCache = mdm
hkcuCache = hkcu
}
// ---------------------------------------------------------------------------
// Refresh — fires a fresh raw read, parses, returns results.
// Used by the 30-minute poll in changeDetector.ts.
// ---------------------------------------------------------------------------
/**
* Fire a fresh MDM subprocess read and parse the results.
* Does NOT update the cache — caller decides whether to apply.
*/
export async function refreshMdmSettings(): Promise<{
mdm: MdmResult
hkcu: MdmResult
}> {
const raw = await fireRawRead()
return consumeRawReadResult(raw)
}
// ---------------------------------------------------------------------------
// Parsing — converts raw subprocess output to validated MdmResult
// ---------------------------------------------------------------------------
/**
* Parse JSON command output (plutil stdout or registry JSON value) into SettingsJson.
* Filters invalid permission rules before schema validation so one bad rule
* doesn't cause the entire MDM settings to be rejected.
*/
export function parseCommandOutputAsSettings(
stdout: string,
sourcePath: string,
): { settings: SettingsJson; errors: ValidationError[] } {
const data = safeParseJSON(stdout, false)
if (!data || typeof data !== 'object') {
return { settings: {}, errors: [] }
}
const ruleWarnings = filterInvalidPermissionRules(data, sourcePath)
const parseResult = SettingsSchema().safeParse(data)
if (!parseResult.success) {
const errors = formatZodError(parseResult.error, sourcePath)
return { settings: {}, errors: [...ruleWarnings, ...errors] }
}
return { settings: parseResult.data, errors: ruleWarnings }
}
/**
* Parse reg query stdout to extract a registry string value.
* Matches both REG_SZ and REG_EXPAND_SZ, case-insensitive.
*
* Expected format:
* Settings REG_SZ {"json":"value"}
*/
export function parseRegQueryStdout(
stdout: string,
valueName = 'Settings',
): string | null {
const lines = stdout.split(/\r?\n/)
const escaped = valueName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`^\\s+${escaped}\\s+REG_(?:EXPAND_)?SZ\\s+(.*)$`, 'i')
for (const line of lines) {
const match = line.match(re)
if (match && match[1]) {
return match[1].trimEnd()
}
}
return null
}
/**
* Convert raw subprocess output into parsed MDM and HKCU results,
* applying the first-source-wins policy.
*/
function consumeRawReadResult(raw: RawReadResult): {
mdm: MdmResult
hkcu: MdmResult
} {
// macOS: plist result (first source wins — already filtered in mdmRawRead)
if (raw.plistStdouts && raw.plistStdouts.length > 0) {
const { stdout, label } = raw.plistStdouts[0]!
const result = parseCommandOutputAsSettings(stdout, label)
if (Object.keys(result.settings).length > 0) {
return { mdm: result, hkcu: EMPTY_RESULT }
}
}
// Windows: HKLM result
if (raw.hklmStdout) {
const jsonString = parseRegQueryStdout(raw.hklmStdout)
if (jsonString) {
const result = parseCommandOutputAsSettings(
jsonString,
`Registry: ${WINDOWS_REGISTRY_KEY_PATH_HKLM}\\${WINDOWS_REGISTRY_VALUE_NAME}`,
)
if (Object.keys(result.settings).length > 0) {
return { mdm: result, hkcu: EMPTY_RESULT }
}
}
}
// No admin MDM — check managed-settings.json before using HKCU
if (hasManagedSettingsFile()) {
return { mdm: EMPTY_RESULT, hkcu: EMPTY_RESULT }
}
// Fall through to HKCU (already read in parallel)
if (raw.hkcuStdout) {
const jsonString = parseRegQueryStdout(raw.hkcuStdout)
if (jsonString) {
const result = parseCommandOutputAsSettings(
jsonString,
`Registry: ${WINDOWS_REGISTRY_KEY_PATH_HKCU}\\${WINDOWS_REGISTRY_VALUE_NAME}`,
)
return { mdm: EMPTY_RESULT, hkcu: result }
}
}
return { mdm: EMPTY_RESULT, hkcu: EMPTY_RESULT }
}
/**
* Check if file-based managed settings (managed-settings.json or any
* managed-settings.d/*.json) exist and have content. Cheap sync check
* used to skip HKCU when a higher-priority file-based source exists.
*/
function hasManagedSettingsFile(): boolean {
try {
const filePath = join(getManagedFilePath(), 'managed-settings.json')
const content = readFileSync(filePath)
const data = safeParseJSON(content, false)
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
return true
}
} catch {
// fall through to drop-in check
}
try {
const dropInDir = getManagedSettingsDropInDir()
const entries = getFsImplementation().readdirSync(dropInDir)
for (const d of entries) {
if (
!(d.isFile() || d.isSymbolicLink()) ||
!d.name.endsWith('.json') ||
d.name.startsWith('.')
) {
continue
}
try {
const content = readFileSync(join(dropInDir, d.name))
const data = safeParseJSON(content, false)
if (data && typeof data === 'object' && Object.keys(data).length > 0) {
return true
}
} catch {
// skip unreadable/malformed file
}
}
} catch {
// drop-in dir doesn't exist
}
return false
}