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