init claude-code
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user