init claude-code
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Shared constants and path builders for MDM settings modules.
|
||||
*
|
||||
* This module has ZERO heavy imports (only `os`) — safe to use from mdmRawRead.ts.
|
||||
* Both mdmRawRead.ts and mdmSettings.ts import from here to avoid duplication.
|
||||
*/
|
||||
|
||||
import { homedir, userInfo } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
/** macOS preference domain for Claude Code MDM profiles. */
|
||||
export const MACOS_PREFERENCE_DOMAIN = 'com.anthropic.claudecode'
|
||||
|
||||
/**
|
||||
* Windows registry key paths for Claude Code MDM policies.
|
||||
*
|
||||
* These keys live under SOFTWARE\Policies which is on the WOW64 shared key
|
||||
* list — both 32-bit and 64-bit processes see the same values without
|
||||
* redirection. Do not move these to SOFTWARE\ClaudeCode, as SOFTWARE is
|
||||
* redirected and 32-bit processes would silently read from WOW6432Node.
|
||||
* See: https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys
|
||||
*/
|
||||
export const WINDOWS_REGISTRY_KEY_PATH_HKLM =
|
||||
'HKLM\\SOFTWARE\\Policies\\ClaudeCode'
|
||||
export const WINDOWS_REGISTRY_KEY_PATH_HKCU =
|
||||
'HKCU\\SOFTWARE\\Policies\\ClaudeCode'
|
||||
|
||||
/** Windows registry value name containing the JSON settings blob. */
|
||||
export const WINDOWS_REGISTRY_VALUE_NAME = 'Settings'
|
||||
|
||||
/** Path to macOS plutil binary. */
|
||||
export const PLUTIL_PATH = '/usr/bin/plutil'
|
||||
|
||||
/** Arguments for plutil to convert plist to JSON on stdout (append plist path). */
|
||||
export const PLUTIL_ARGS_PREFIX = ['-convert', 'json', '-o', '-', '--'] as const
|
||||
|
||||
/** Subprocess timeout in milliseconds. */
|
||||
export const MDM_SUBPROCESS_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Build the list of macOS plist paths in priority order (highest first).
|
||||
* Evaluates `process.env.USER_TYPE` at call time so ant-only paths are
|
||||
* included only when appropriate.
|
||||
*/
|
||||
export function getMacOSPlistPaths(): Array<{ path: string; label: string }> {
|
||||
let username = ''
|
||||
try {
|
||||
username = userInfo().username
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const paths: Array<{ path: string; label: string }> = []
|
||||
|
||||
if (username) {
|
||||
paths.push({
|
||||
path: `/Library/Managed Preferences/${username}/${MACOS_PREFERENCE_DOMAIN}.plist`,
|
||||
label: 'per-user managed preferences',
|
||||
})
|
||||
}
|
||||
|
||||
paths.push({
|
||||
path: `/Library/Managed Preferences/${MACOS_PREFERENCE_DOMAIN}.plist`,
|
||||
label: 'device-level managed preferences',
|
||||
})
|
||||
|
||||
// Allow user-writable preferences for local MDM testing in ant builds only.
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
paths.push({
|
||||
path: join(
|
||||
homedir(),
|
||||
'Library',
|
||||
'Preferences',
|
||||
`${MACOS_PREFERENCE_DOMAIN}.plist`,
|
||||
),
|
||||
label: 'user preferences (ant-only)',
|
||||
})
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Minimal module for firing MDM subprocess reads without blocking the event loop.
|
||||
* Has minimal imports — only child_process, fs, and mdmConstants (which only imports os).
|
||||
*
|
||||
* Two usage patterns:
|
||||
* 1. Startup: startMdmRawRead() fires at main.tsx module evaluation, results consumed later via getMdmRawReadPromise()
|
||||
* 2. Poll/fallback: fireRawRead() creates a fresh read on demand (used by changeDetector and SDK entrypoint)
|
||||
*
|
||||
* Raw stdout is consumed by mdmSettings.ts via consumeRawReadResult().
|
||||
*/
|
||||
|
||||
import { execFile } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import {
|
||||
getMacOSPlistPaths,
|
||||
MDM_SUBPROCESS_TIMEOUT_MS,
|
||||
PLUTIL_ARGS_PREFIX,
|
||||
PLUTIL_PATH,
|
||||
WINDOWS_REGISTRY_KEY_PATH_HKCU,
|
||||
WINDOWS_REGISTRY_KEY_PATH_HKLM,
|
||||
WINDOWS_REGISTRY_VALUE_NAME,
|
||||
} from './constants.js'
|
||||
|
||||
export type RawReadResult = {
|
||||
plistStdouts: Array<{ stdout: string; label: string }> | null
|
||||
hklmStdout: string | null
|
||||
hkcuStdout: string | null
|
||||
}
|
||||
|
||||
let rawReadPromise: Promise<RawReadResult> | null = null
|
||||
|
||||
function execFilePromise(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
): Promise<{ stdout: string; code: number | null }> {
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
cmd,
|
||||
args,
|
||||
{ encoding: 'utf-8', timeout: MDM_SUBPROCESS_TIMEOUT_MS },
|
||||
(err, stdout) => {
|
||||
// biome-ignore lint/nursery/noFloatingPromises: resolve() is not a floating promise
|
||||
resolve({ stdout: stdout ?? '', code: err ? 1 : 0 })
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire fresh subprocess reads for MDM settings and return raw stdout.
|
||||
* On macOS: spawns plutil for each plist path in parallel, picks first winner.
|
||||
* On Windows: spawns reg query for HKLM and HKCU in parallel.
|
||||
* On Linux: returns empty (no MDM equivalent).
|
||||
*/
|
||||
export function fireRawRead(): Promise<RawReadResult> {
|
||||
return (async (): Promise<RawReadResult> => {
|
||||
if (process.platform === 'darwin') {
|
||||
const plistPaths = getMacOSPlistPaths()
|
||||
|
||||
const allResults = await Promise.all(
|
||||
plistPaths.map(async ({ path, label }) => {
|
||||
// Fast-path: skip the plutil subprocess if the plist file does not
|
||||
// exist. Spawning plutil takes ~5ms even for an immediate ENOENT,
|
||||
// and non-MDM machines never have these files.
|
||||
// Uses synchronous existsSync to preserve the spawn-during-imports
|
||||
// invariant: execFilePromise must be the first await so plutil
|
||||
// spawns before the event loop polls (see main.tsx:3-4).
|
||||
if (!existsSync(path)) {
|
||||
return { stdout: '', label, ok: false }
|
||||
}
|
||||
const { stdout, code } = await execFilePromise(PLUTIL_PATH, [
|
||||
...PLUTIL_ARGS_PREFIX,
|
||||
path,
|
||||
])
|
||||
return { stdout, label, ok: code === 0 && !!stdout }
|
||||
}),
|
||||
)
|
||||
|
||||
// First source wins (array is in priority order)
|
||||
const winner = allResults.find(r => r.ok)
|
||||
return {
|
||||
plistStdouts: winner
|
||||
? [{ stdout: winner.stdout, label: winner.label }]
|
||||
: [],
|
||||
hklmStdout: null,
|
||||
hkcuStdout: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const [hklm, hkcu] = await Promise.all([
|
||||
execFilePromise('reg', [
|
||||
'query',
|
||||
WINDOWS_REGISTRY_KEY_PATH_HKLM,
|
||||
'/v',
|
||||
WINDOWS_REGISTRY_VALUE_NAME,
|
||||
]),
|
||||
execFilePromise('reg', [
|
||||
'query',
|
||||
WINDOWS_REGISTRY_KEY_PATH_HKCU,
|
||||
'/v',
|
||||
WINDOWS_REGISTRY_VALUE_NAME,
|
||||
]),
|
||||
])
|
||||
return {
|
||||
plistStdouts: null,
|
||||
hklmStdout: hklm.code === 0 ? hklm.stdout : null,
|
||||
hkcuStdout: hkcu.code === 0 ? hkcu.stdout : null,
|
||||
}
|
||||
}
|
||||
|
||||
return { plistStdouts: null, hklmStdout: null, hkcuStdout: null }
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire raw subprocess reads once for startup. Called at main.tsx module evaluation.
|
||||
* Results are consumed via getMdmRawReadPromise().
|
||||
*/
|
||||
export function startMdmRawRead(): void {
|
||||
if (rawReadPromise) return
|
||||
rawReadPromise = fireRawRead()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the startup promise. Returns null if startMdmRawRead() wasn't called.
|
||||
*/
|
||||
export function getMdmRawReadPromise(): Promise<RawReadResult> | null {
|
||||
return rawReadPromise
|
||||
}
|
||||
@@ -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