settings.ts
1 /** 2 * MDM (Mobile Device Management) profile enforcement for Claude Code managed settings. 3 * 4 * Reads enterprise settings from OS-level MDM configuration: 5 * - macOS: `com.anthropic.claudecode` preference domain 6 * (MDM profiles at /Library/Managed Preferences/ only — not user-writable ~/Library/Preferences/) 7 * - Windows: `HKLM\SOFTWARE\Policies\ClaudeCode` (admin-only) 8 * and `HKCU\SOFTWARE\Policies\ClaudeCode` (user-writable, lowest priority) 9 * - Linux: No MDM equivalent (uses /etc/claude-code/managed-settings.json instead) 10 * 11 * Policy settings use "first source wins" — the highest-priority source that exists 12 * provides all policy settings. Priority (highest to lowest): 13 * remote → HKLM/plist → managed-settings.json → HKCU 14 * 15 * Architecture: 16 * constants.ts — shared constants and plist path builder (zero heavy imports) 17 * rawRead.ts — subprocess I/O only (zero heavy imports, fires at main.tsx evaluation) 18 * settings.ts — parsing, caching, first-source-wins logic (this file) 19 */ 20 21 import { join } from 'path' 22 import { logForDebugging } from '../../debug.js' 23 import { logForDiagnosticsNoPII } from '../../diagLogs.js' 24 import { readFileSync } from '../../fileRead.js' 25 import { getFsImplementation } from '../../fsOperations.js' 26 import { safeParseJSON } from '../../json.js' 27 import { profileCheckpoint } from '../../startupProfiler.js' 28 import { 29 getManagedFilePath, 30 getManagedSettingsDropInDir, 31 } from '../managedPath.js' 32 import { type SettingsJson, SettingsSchema } from '../types.js' 33 import { 34 filterInvalidPermissionRules, 35 formatZodError, 36 type ValidationError, 37 } from '../validation.js' 38 import { 39 WINDOWS_REGISTRY_KEY_PATH_HKCU, 40 WINDOWS_REGISTRY_KEY_PATH_HKLM, 41 WINDOWS_REGISTRY_VALUE_NAME, 42 } from './constants.js' 43 import { 44 fireRawRead, 45 getMdmRawReadPromise, 46 type RawReadResult, 47 } from './rawRead.js' 48 49 // --------------------------------------------------------------------------- 50 // Types and cache 51 // --------------------------------------------------------------------------- 52 53 type MdmResult = { settings: SettingsJson; errors: ValidationError[] } 54 const EMPTY_RESULT: MdmResult = Object.freeze({ settings: {}, errors: [] }) 55 let mdmCache: MdmResult | null = null 56 let hkcuCache: MdmResult | null = null 57 let mdmLoadPromise: Promise<void> | null = null 58 59 // --------------------------------------------------------------------------- 60 // Startup load — fires early, awaited before first settings read 61 // --------------------------------------------------------------------------- 62 63 /** 64 * Kick off async MDM/HKCU reads. Call this as early as possible in 65 * startup so the subprocess runs in parallel with module loading. 66 */ 67 export function startMdmSettingsLoad(): void { 68 if (mdmLoadPromise) return 69 mdmLoadPromise = (async () => { 70 profileCheckpoint('mdm_load_start') 71 const startTime = Date.now() 72 73 // Use the startup raw read if cli.tsx fired it, otherwise fire a fresh one. 74 // Both paths produce the same RawReadResult; consumeRawReadResult parses it. 75 const rawPromise = getMdmRawReadPromise() ?? fireRawRead() 76 const { mdm, hkcu } = consumeRawReadResult(await rawPromise) 77 mdmCache = mdm 78 hkcuCache = hkcu 79 profileCheckpoint('mdm_load_end') 80 81 const duration = Date.now() - startTime 82 logForDebugging(`MDM settings load completed in ${duration}ms`) 83 if (Object.keys(mdm.settings).length > 0) { 84 logForDebugging( 85 `MDM settings found: ${Object.keys(mdm.settings).join(', ')}`, 86 ) 87 try { 88 logForDiagnosticsNoPII('info', 'mdm_settings_loaded', { 89 duration_ms: duration, 90 key_count: Object.keys(mdm.settings).length, 91 error_count: mdm.errors.length, 92 }) 93 } catch { 94 // Diagnostic logging is best-effort 95 } 96 } 97 })() 98 } 99 100 /** 101 * Await the in-flight MDM load. Call this before the first settings read. 102 * If startMdmSettingsLoad() was called early enough, this resolves immediately. 103 */ 104 export async function ensureMdmSettingsLoaded(): Promise<void> { 105 if (!mdmLoadPromise) { 106 startMdmSettingsLoad() 107 } 108 await mdmLoadPromise 109 } 110 111 // --------------------------------------------------------------------------- 112 // Sync cache readers — used by the settings pipeline (loadSettingsFromDisk) 113 // --------------------------------------------------------------------------- 114 115 /** 116 * Read admin-controlled MDM settings from the session cache. 117 * 118 * Returns settings from admin-only sources: 119 * - macOS: /Library/Managed Preferences/ (requires root) 120 * - Windows: HKLM registry (requires admin) 121 * 122 * Does NOT include HKCU (user-writable) — use getHkcuSettings() for that. 123 */ 124 export function getMdmSettings(): MdmResult { 125 return mdmCache ?? EMPTY_RESULT 126 } 127 128 /** 129 * Read HKCU registry settings (user-writable, lowest policy priority). 130 * Only relevant on Windows — returns empty on other platforms. 131 */ 132 export function getHkcuSettings(): MdmResult { 133 return hkcuCache ?? EMPTY_RESULT 134 } 135 136 // --------------------------------------------------------------------------- 137 // Cache management 138 // --------------------------------------------------------------------------- 139 140 /** 141 * Clear the MDM and HKCU settings caches, forcing a fresh read on next load. 142 */ 143 export function clearMdmSettingsCache(): void { 144 mdmCache = null 145 hkcuCache = null 146 mdmLoadPromise = null 147 } 148 149 /** 150 * Update the session caches directly. Used by the change detector poll. 151 */ 152 export function setMdmSettingsCache(mdm: MdmResult, hkcu: MdmResult): void { 153 mdmCache = mdm 154 hkcuCache = hkcu 155 } 156 157 // --------------------------------------------------------------------------- 158 // Refresh — fires a fresh raw read, parses, returns results. 159 // Used by the 30-minute poll in changeDetector.ts. 160 // --------------------------------------------------------------------------- 161 162 /** 163 * Fire a fresh MDM subprocess read and parse the results. 164 * Does NOT update the cache — caller decides whether to apply. 165 */ 166 export async function refreshMdmSettings(): Promise<{ 167 mdm: MdmResult 168 hkcu: MdmResult 169 }> { 170 const raw = await fireRawRead() 171 return consumeRawReadResult(raw) 172 } 173 174 // --------------------------------------------------------------------------- 175 // Parsing — converts raw subprocess output to validated MdmResult 176 // --------------------------------------------------------------------------- 177 178 /** 179 * Parse JSON command output (plutil stdout or registry JSON value) into SettingsJson. 180 * Filters invalid permission rules before schema validation so one bad rule 181 * doesn't cause the entire MDM settings to be rejected. 182 */ 183 export function parseCommandOutputAsSettings( 184 stdout: string, 185 sourcePath: string, 186 ): { settings: SettingsJson; errors: ValidationError[] } { 187 const data = safeParseJSON(stdout, false) 188 if (!data || typeof data !== 'object') { 189 return { settings: {}, errors: [] } 190 } 191 192 const ruleWarnings = filterInvalidPermissionRules(data, sourcePath) 193 const parseResult = SettingsSchema().safeParse(data) 194 if (!parseResult.success) { 195 const errors = formatZodError(parseResult.error, sourcePath) 196 return { settings: {}, errors: [...ruleWarnings, ...errors] } 197 } 198 return { settings: parseResult.data, errors: ruleWarnings } 199 } 200 201 /** 202 * Parse reg query stdout to extract a registry string value. 203 * Matches both REG_SZ and REG_EXPAND_SZ, case-insensitive. 204 * 205 * Expected format: 206 * Settings REG_SZ {"json":"value"} 207 */ 208 export function parseRegQueryStdout( 209 stdout: string, 210 valueName = 'Settings', 211 ): string | null { 212 const lines = stdout.split(/\r?\n/) 213 const escaped = valueName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 214 const re = new RegExp(`^\\s+${escaped}\\s+REG_(?:EXPAND_)?SZ\\s+(.*)$`, 'i') 215 for (const line of lines) { 216 const match = line.match(re) 217 if (match && match[1]) { 218 return match[1].trimEnd() 219 } 220 } 221 return null 222 } 223 224 /** 225 * Convert raw subprocess output into parsed MDM and HKCU results, 226 * applying the first-source-wins policy. 227 */ 228 function consumeRawReadResult(raw: RawReadResult): { 229 mdm: MdmResult 230 hkcu: MdmResult 231 } { 232 // macOS: plist result (first source wins — already filtered in mdmRawRead) 233 if (raw.plistStdouts && raw.plistStdouts.length > 0) { 234 const { stdout, label } = raw.plistStdouts[0]! 235 const result = parseCommandOutputAsSettings(stdout, label) 236 if (Object.keys(result.settings).length > 0) { 237 return { mdm: result, hkcu: EMPTY_RESULT } 238 } 239 } 240 241 // Windows: HKLM result 242 if (raw.hklmStdout) { 243 const jsonString = parseRegQueryStdout(raw.hklmStdout) 244 if (jsonString) { 245 const result = parseCommandOutputAsSettings( 246 jsonString, 247 `Registry: ${WINDOWS_REGISTRY_KEY_PATH_HKLM}\\${WINDOWS_REGISTRY_VALUE_NAME}`, 248 ) 249 if (Object.keys(result.settings).length > 0) { 250 return { mdm: result, hkcu: EMPTY_RESULT } 251 } 252 } 253 } 254 255 // No admin MDM — check managed-settings.json before using HKCU 256 if (hasManagedSettingsFile()) { 257 return { mdm: EMPTY_RESULT, hkcu: EMPTY_RESULT } 258 } 259 260 // Fall through to HKCU (already read in parallel) 261 if (raw.hkcuStdout) { 262 const jsonString = parseRegQueryStdout(raw.hkcuStdout) 263 if (jsonString) { 264 const result = parseCommandOutputAsSettings( 265 jsonString, 266 `Registry: ${WINDOWS_REGISTRY_KEY_PATH_HKCU}\\${WINDOWS_REGISTRY_VALUE_NAME}`, 267 ) 268 return { mdm: EMPTY_RESULT, hkcu: result } 269 } 270 } 271 272 return { mdm: EMPTY_RESULT, hkcu: EMPTY_RESULT } 273 } 274 275 /** 276 * Check if file-based managed settings (managed-settings.json or any 277 * managed-settings.d/*.json) exist and have content. Cheap sync check 278 * used to skip HKCU when a higher-priority file-based source exists. 279 */ 280 function hasManagedSettingsFile(): boolean { 281 try { 282 const filePath = join(getManagedFilePath(), 'managed-settings.json') 283 const content = readFileSync(filePath) 284 const data = safeParseJSON(content, false) 285 if (data && typeof data === 'object' && Object.keys(data).length > 0) { 286 return true 287 } 288 } catch { 289 // fall through to drop-in check 290 } 291 try { 292 const dropInDir = getManagedSettingsDropInDir() 293 const entries = getFsImplementation().readdirSync(dropInDir) 294 for (const d of entries) { 295 if ( 296 !(d.isFile() || d.isSymbolicLink()) || 297 !d.name.endsWith('.json') || 298 d.name.startsWith('.') 299 ) { 300 continue 301 } 302 try { 303 const content = readFileSync(join(dropInDir, d.name)) 304 const data = safeParseJSON(content, false) 305 if (data && typeof data === 'object' && Object.keys(data).length > 0) { 306 return true 307 } 308 } catch { 309 // skip unreadable/malformed file 310 } 311 } 312 } catch { 313 // drop-in dir doesn't exist 314 } 315 return false 316 }