File size: 10,716 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | /**
* 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
}
|