317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
/**
|
|
* 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
|
|
}
|