init claude-code
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Combines settings validation errors with MCP configuration errors.
|
||||
*
|
||||
* This module exists to break a circular dependency:
|
||||
* settings.ts → mcp/config.ts → settings.ts
|
||||
*
|
||||
* By moving the MCP error aggregation here (a leaf that imports both
|
||||
* settings.ts and mcp/config.ts, but is imported by neither), the cycle
|
||||
* is eliminated.
|
||||
*/
|
||||
|
||||
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
|
||||
import { getSettingsWithErrors } from './settings.js'
|
||||
import type { SettingsWithErrors } from './validation.js'
|
||||
|
||||
/**
|
||||
* Get merged settings with all validation errors, including MCP config errors.
|
||||
*
|
||||
* Use this instead of getSettingsWithErrors() when you need the full set of
|
||||
* errors (settings + MCP). The underlying getSettingsWithErrors() no longer
|
||||
* includes MCP errors to avoid the circular dependency.
|
||||
*/
|
||||
export function getSettingsWithAllErrors(): SettingsWithErrors {
|
||||
const result = getSettingsWithErrors()
|
||||
// 'dynamic' scope does not have errors returned; it throws and is set on cli startup
|
||||
const scopes = ['user', 'project', 'local'] as const
|
||||
const mcpErrors = scopes.flatMap(scope => getMcpConfigsByScope(scope).errors)
|
||||
return {
|
||||
settings: result.settings,
|
||||
errors: [...result.errors, ...mcpErrors],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { updateHooksConfigSnapshot } from '../hooks/hooksConfigSnapshot.js'
|
||||
import {
|
||||
createDisabledBypassPermissionsContext,
|
||||
findOverlyBroadBashPermissions,
|
||||
isBypassPermissionsModeDisabled,
|
||||
removeDangerousPermissions,
|
||||
transitionPlanAutoMode,
|
||||
} from '../permissions/permissionSetup.js'
|
||||
import { syncPermissionRulesFromDisk } from '../permissions/permissions.js'
|
||||
import { loadAllPermissionRulesFromDisk } from '../permissions/permissionsLoader.js'
|
||||
import type { SettingSource } from './constants.js'
|
||||
import { getInitialSettings } from './settings.js'
|
||||
|
||||
/**
|
||||
* Apply a settings change to app state. Re-reads settings from disk,
|
||||
* reloads permissions and hooks, and pushes the new state.
|
||||
*
|
||||
* Used by both the interactive path (AppState.tsx via useSettingsChange) and
|
||||
* the headless/SDK path (print.ts direct subscribe) so that managed-settings
|
||||
* / policy changes are fully applied in both modes.
|
||||
*
|
||||
* The settings cache is reset by the notifier (changeDetector.fanOut) before
|
||||
* listeners are iterated, so getInitialSettings() here reads fresh disk
|
||||
* state. Previously this function reset the cache itself, which — combined
|
||||
* with useSettingsChange's own reset — caused N disk reloads per notification
|
||||
* for N subscribers.
|
||||
*
|
||||
* Side-effects like clearing auth caches and applying env vars are handled by
|
||||
* `onChangeAppState` which fires when `settings` changes in state.
|
||||
*/
|
||||
export function applySettingsChange(
|
||||
source: SettingSource,
|
||||
setAppState: (f: (prev: AppState) => AppState) => void,
|
||||
): void {
|
||||
const newSettings = getInitialSettings()
|
||||
|
||||
logForDebugging(`Settings changed from ${source}, updating app state`)
|
||||
|
||||
const updatedRules = loadAllPermissionRulesFromDisk()
|
||||
updateHooksConfigSnapshot()
|
||||
|
||||
setAppState(prev => {
|
||||
let newContext = syncPermissionRulesFromDisk(
|
||||
prev.toolPermissionContext,
|
||||
updatedRules,
|
||||
)
|
||||
|
||||
// Ant-only: re-strip overly broad Bash allow rules after settings sync
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent'
|
||||
) {
|
||||
const overlyBroad = findOverlyBroadBashPermissions(updatedRules, [])
|
||||
if (overlyBroad.length > 0) {
|
||||
newContext = removeDangerousPermissions(newContext, overlyBroad)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
newContext.isBypassPermissionsModeAvailable &&
|
||||
isBypassPermissionsModeDisabled()
|
||||
) {
|
||||
newContext = createDisabledBypassPermissionsContext(newContext)
|
||||
}
|
||||
|
||||
newContext = transitionPlanAutoMode(newContext)
|
||||
|
||||
// Sync effortLevel from settings to top-level AppState when it changes
|
||||
// (e.g. via applyFlagSettings from IDE). Only propagate if the setting
|
||||
// itself changed — otherwise unrelated settings churn (e.g. tips dismissal
|
||||
// on startup) would clobber a --effort CLI flag value held in AppState.
|
||||
const prevEffort = prev.settings.effortLevel
|
||||
const newEffort = newSettings.effortLevel
|
||||
const effortChanged = prevEffort !== newEffort
|
||||
|
||||
return {
|
||||
...prev,
|
||||
settings: newSettings,
|
||||
toolPermissionContext: newContext,
|
||||
// Only propagate a defined new value — when the disk key is absent
|
||||
// (e.g. /effort max for non-ants writes undefined; --effort CLI flag),
|
||||
// prev.settings.effortLevel can be stale (internal writes suppress the
|
||||
// watcher that would resync AppState.settings), so effortChanged would
|
||||
// be true and we'd wipe a session-scoped value held in effortValue.
|
||||
...(effortChanged && newEffort !== undefined
|
||||
? { effortValue: newEffort }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import chokidar, { type FSWatcher } from 'chokidar'
|
||||
import { stat } from 'fs/promises'
|
||||
import * as platformPath from 'path'
|
||||
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
||||
import { registerCleanup } from '../cleanupRegistry.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { errorMessage } from '../errors.js'
|
||||
import {
|
||||
type ConfigChangeSource,
|
||||
executeConfigChangeHooks,
|
||||
hasBlockingResult,
|
||||
} from '../hooks.js'
|
||||
import { createSignal } from '../signal.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import { SETTING_SOURCES, type SettingSource } from './constants.js'
|
||||
import { clearInternalWrites, consumeInternalWrite } from './internalWrites.js'
|
||||
import { getManagedSettingsDropInDir } from './managedPath.js'
|
||||
import {
|
||||
getHkcuSettings,
|
||||
getMdmSettings,
|
||||
refreshMdmSettings,
|
||||
setMdmSettingsCache,
|
||||
} from './mdm/settings.js'
|
||||
import { getSettingsFilePathForSource } from './settings.js'
|
||||
import { resetSettingsCache } from './settingsCache.js'
|
||||
|
||||
/**
|
||||
* Time in milliseconds to wait for file writes to stabilize before processing.
|
||||
* This helps avoid processing partial writes or rapid successive changes.
|
||||
*/
|
||||
const FILE_STABILITY_THRESHOLD_MS = 1000
|
||||
|
||||
/**
|
||||
* Polling interval in milliseconds for checking file stability.
|
||||
* Used by chokidar's awaitWriteFinish option.
|
||||
* Must be lower than FILE_STABILITY_THRESHOLD_MS.
|
||||
*/
|
||||
const FILE_STABILITY_POLL_INTERVAL_MS = 500
|
||||
|
||||
/**
|
||||
* Time window in milliseconds to consider a file change as internal.
|
||||
* If a file change occurs within this window after markInternalWrite() is called,
|
||||
* it's assumed to be from Claude Code itself and won't trigger a notification.
|
||||
*/
|
||||
const INTERNAL_WRITE_WINDOW_MS = 5000
|
||||
|
||||
/**
|
||||
* Poll interval for MDM settings (registry/plist) changes.
|
||||
* These can't be watched via filesystem events, so we poll periodically.
|
||||
*/
|
||||
const MDM_POLL_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
|
||||
|
||||
/**
|
||||
* Grace period in milliseconds before processing a settings file deletion.
|
||||
* Handles the common delete-and-recreate pattern during auto-updates or when
|
||||
* another session starts up. If an `add` or `change` event fires within this
|
||||
* window (file was recreated), the deletion is cancelled and treated as a change.
|
||||
*
|
||||
* Must exceed chokidar's awaitWriteFinish delay (stabilityThreshold + pollInterval)
|
||||
* so the grace window outlasts the write stability check on the recreated file.
|
||||
*/
|
||||
const DELETION_GRACE_MS =
|
||||
FILE_STABILITY_THRESHOLD_MS + FILE_STABILITY_POLL_INTERVAL_MS + 200
|
||||
|
||||
let watcher: FSWatcher | null = null
|
||||
let mdmPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lastMdmSnapshot: string | null = null
|
||||
let initialized = false
|
||||
let disposed = false
|
||||
const pendingDeletions = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const settingsChanged = createSignal<[source: SettingSource]>()
|
||||
|
||||
// Test overrides for timing constants
|
||||
let testOverrides: {
|
||||
stabilityThreshold?: number
|
||||
pollInterval?: number
|
||||
mdmPollInterval?: number
|
||||
deletionGrace?: number
|
||||
} | null = null
|
||||
|
||||
/**
|
||||
* Initialize file watching
|
||||
*/
|
||||
export async function initialize(): Promise<void> {
|
||||
if (getIsRemoteMode()) return
|
||||
if (initialized || disposed) return
|
||||
initialized = true
|
||||
|
||||
// Start MDM poll for registry/plist changes (independent of filesystem watching)
|
||||
startMdmPoll()
|
||||
|
||||
// Register cleanup to properly dispose during graceful shutdown
|
||||
registerCleanup(dispose)
|
||||
|
||||
const { dirs, settingsFiles, dropInDir } = await getWatchTargets()
|
||||
if (disposed) return // dispose() ran during the await
|
||||
if (dirs.length === 0) return
|
||||
|
||||
logForDebugging(
|
||||
`Watching for changes in setting files ${[...settingsFiles].join(', ')}...${dropInDir ? ` and drop-in directory ${dropInDir}` : ''}`,
|
||||
)
|
||||
|
||||
watcher = chokidar.watch(dirs, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
depth: 0, // Only watch immediate children, not subdirectories
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold:
|
||||
testOverrides?.stabilityThreshold ?? FILE_STABILITY_THRESHOLD_MS,
|
||||
pollInterval:
|
||||
testOverrides?.pollInterval ?? FILE_STABILITY_POLL_INTERVAL_MS,
|
||||
},
|
||||
ignored: (path, stats) => {
|
||||
// Ignore special file types (sockets, FIFOs, devices) - they cannot be watched
|
||||
// and will error with EOPNOTSUPP on macOS.
|
||||
if (stats && !stats.isFile() && !stats.isDirectory()) return true
|
||||
// Ignore .git directories
|
||||
if (path.split(platformPath.sep).some(dir => dir === '.git')) return true
|
||||
// Allow directories (chokidar needs them for directory-level watching)
|
||||
// and paths without stats (chokidar's initial check before stat)
|
||||
if (!stats || stats.isDirectory()) return false
|
||||
// Only watch known settings files, ignore everything else in the directory
|
||||
// Note: chokidar normalizes paths to forward slashes on Windows, so we
|
||||
// normalize back to native format for comparison
|
||||
const normalized = platformPath.normalize(path)
|
||||
if (settingsFiles.has(normalized)) return false
|
||||
// Also accept .json files inside the managed-settings.d/ drop-in directory
|
||||
if (
|
||||
dropInDir &&
|
||||
normalized.startsWith(dropInDir + platformPath.sep) &&
|
||||
normalized.endsWith('.json')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
// Additional options for stability
|
||||
ignorePermissionErrors: true,
|
||||
usePolling: false, // Use native file system events
|
||||
atomic: true, // Handle atomic writes better
|
||||
})
|
||||
|
||||
watcher.on('change', handleChange)
|
||||
watcher.on('unlink', handleDelete)
|
||||
watcher.on('add', handleAdd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up file watcher. Returns a promise that resolves when chokidar's
|
||||
* close() settles — callers that need the watcher fully stopped before
|
||||
* removing the watched directory (e.g. test teardown) must await this.
|
||||
* Fire-and-forget is still valid where timing doesn't matter.
|
||||
*/
|
||||
export function dispose(): Promise<void> {
|
||||
disposed = true
|
||||
if (mdmPollTimer) {
|
||||
clearInterval(mdmPollTimer)
|
||||
mdmPollTimer = null
|
||||
}
|
||||
for (const timer of pendingDeletions.values()) clearTimeout(timer)
|
||||
pendingDeletions.clear()
|
||||
lastMdmSnapshot = null
|
||||
clearInternalWrites()
|
||||
settingsChanged.clear()
|
||||
const w = watcher
|
||||
watcher = null
|
||||
return w ? w.close() : Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to settings changes
|
||||
*/
|
||||
export const subscribe = settingsChanged.subscribe
|
||||
|
||||
/**
|
||||
* Collect settings file paths and their deduplicated parent directories to watch.
|
||||
* Returns all potential settings file paths for watched directories, not just those
|
||||
* that exist at init time, so that newly-created files are also detected.
|
||||
*/
|
||||
async function getWatchTargets(): Promise<{
|
||||
dirs: string[]
|
||||
settingsFiles: Set<string>
|
||||
dropInDir: string | null
|
||||
}> {
|
||||
// Map from directory to all potential settings files in that directory
|
||||
const dirToSettingsFiles = new Map<string, Set<string>>()
|
||||
const dirsWithExistingFiles = new Set<string>()
|
||||
|
||||
for (const source of SETTING_SOURCES) {
|
||||
// Skip flagSettings - they're provided via CLI and won't change during the session.
|
||||
// Additionally, they may be temp files in $TMPDIR which can contain special files
|
||||
// (FIFOs, sockets) that cause the file watcher to hang or error.
|
||||
// See: https://github.com/anthropics/claude-code/issues/16469
|
||||
if (source === 'flagSettings') {
|
||||
continue
|
||||
}
|
||||
const path = getSettingsFilePathForSource(source)
|
||||
if (!path) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dir = platformPath.dirname(path)
|
||||
|
||||
// Track all potential settings files in each directory
|
||||
if (!dirToSettingsFiles.has(dir)) {
|
||||
dirToSettingsFiles.set(dir, new Set())
|
||||
}
|
||||
dirToSettingsFiles.get(dir)!.add(path)
|
||||
|
||||
// Check if file exists - only watch directories that have at least one existing file
|
||||
try {
|
||||
const stats = await stat(path)
|
||||
if (stats.isFile()) {
|
||||
dirsWithExistingFiles.add(dir)
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
}
|
||||
|
||||
// For watched directories, include ALL potential settings file paths
|
||||
// This ensures files created after init are also detected
|
||||
const settingsFiles = new Set<string>()
|
||||
for (const dir of dirsWithExistingFiles) {
|
||||
const filesInDir = dirToSettingsFiles.get(dir)
|
||||
if (filesInDir) {
|
||||
for (const file of filesInDir) {
|
||||
settingsFiles.add(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also watch the managed-settings.d/ drop-in directory for policy fragments.
|
||||
// We add it as a separate watched directory so chokidar's depth:0 watches
|
||||
// its immediate children (the .json files). Any .json file inside it maps
|
||||
// to the 'policySettings' source.
|
||||
let dropInDir: string | null = null
|
||||
const managedDropIn = getManagedSettingsDropInDir()
|
||||
try {
|
||||
const stats = await stat(managedDropIn)
|
||||
if (stats.isDirectory()) {
|
||||
dirsWithExistingFiles.add(managedDropIn)
|
||||
dropInDir = managedDropIn
|
||||
}
|
||||
} catch {
|
||||
// Drop-in directory doesn't exist, that's fine
|
||||
}
|
||||
|
||||
return { dirs: [...dirsWithExistingFiles], settingsFiles, dropInDir }
|
||||
}
|
||||
|
||||
function settingSourceToConfigChangeSource(
|
||||
source: SettingSource,
|
||||
): ConfigChangeSource {
|
||||
switch (source) {
|
||||
case 'userSettings':
|
||||
return 'user_settings'
|
||||
case 'projectSettings':
|
||||
return 'project_settings'
|
||||
case 'localSettings':
|
||||
return 'local_settings'
|
||||
case 'flagSettings':
|
||||
case 'policySettings':
|
||||
return 'policy_settings'
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(path: string): void {
|
||||
const source = getSourceForPath(path)
|
||||
if (!source) return
|
||||
|
||||
// If a deletion was pending for this path (delete-and-recreate pattern),
|
||||
// cancel the deletion — we'll process this as a change instead.
|
||||
const pendingTimer = pendingDeletions.get(path)
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer)
|
||||
pendingDeletions.delete(path)
|
||||
logForDebugging(
|
||||
`Cancelled pending deletion of ${path} — file was recreated`,
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this was an internal write
|
||||
if (consumeInternalWrite(path, INTERNAL_WRITE_WINDOW_MS)) {
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(`Detected change to ${path}`)
|
||||
|
||||
// Fire ConfigChange hook first — if blocked (exit code 2 or decision: 'block'),
|
||||
// skip applying the change to the session
|
||||
void executeConfigChangeHooks(
|
||||
settingSourceToConfigChangeSource(source),
|
||||
path,
|
||||
).then(results => {
|
||||
if (hasBlockingResult(results)) {
|
||||
logForDebugging(`ConfigChange hook blocked change to ${path}`)
|
||||
return
|
||||
}
|
||||
fanOut(source)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a file being re-added (e.g. after a delete-and-recreate). Cancels any
|
||||
* pending deletion grace timer and treats the event as a change.
|
||||
*/
|
||||
function handleAdd(path: string): void {
|
||||
const source = getSourceForPath(path)
|
||||
if (!source) return
|
||||
|
||||
// Cancel any pending deletion — the file is back
|
||||
const pendingTimer = pendingDeletions.get(path)
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer)
|
||||
pendingDeletions.delete(path)
|
||||
logForDebugging(`Cancelled pending deletion of ${path} — file was re-added`)
|
||||
}
|
||||
|
||||
// Treat as a change (re-read settings)
|
||||
handleChange(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a file being deleted. Uses a grace period to absorb delete-and-recreate
|
||||
* patterns (e.g. auto-updater, another session starting up). If the file is
|
||||
* recreated within the grace period (detected via 'add' or 'change' event),
|
||||
* the deletion is cancelled and treated as a normal change instead.
|
||||
*/
|
||||
function handleDelete(path: string): void {
|
||||
const source = getSourceForPath(path)
|
||||
if (!source) return
|
||||
|
||||
logForDebugging(`Detected deletion of ${path}`)
|
||||
|
||||
// If there's already a pending deletion for this path, let it run
|
||||
if (pendingDeletions.has(path)) return
|
||||
|
||||
const timer = setTimeout(
|
||||
(p, src) => {
|
||||
pendingDeletions.delete(p)
|
||||
|
||||
// Fire ConfigChange hook first — if blocked, skip applying the deletion
|
||||
void executeConfigChangeHooks(
|
||||
settingSourceToConfigChangeSource(src),
|
||||
p,
|
||||
).then(results => {
|
||||
if (hasBlockingResult(results)) {
|
||||
logForDebugging(`ConfigChange hook blocked deletion of ${p}`)
|
||||
return
|
||||
}
|
||||
fanOut(src)
|
||||
})
|
||||
},
|
||||
testOverrides?.deletionGrace ?? DELETION_GRACE_MS,
|
||||
path,
|
||||
source,
|
||||
)
|
||||
pendingDeletions.set(path, timer)
|
||||
}
|
||||
|
||||
function getSourceForPath(path: string): SettingSource | undefined {
|
||||
// Normalize path because chokidar uses forward slashes on Windows
|
||||
const normalizedPath = platformPath.normalize(path)
|
||||
|
||||
// Check if the path is inside the managed-settings.d/ drop-in directory
|
||||
const dropInDir = getManagedSettingsDropInDir()
|
||||
if (normalizedPath.startsWith(dropInDir + platformPath.sep)) {
|
||||
return 'policySettings'
|
||||
}
|
||||
|
||||
return SETTING_SOURCES.find(
|
||||
source => getSettingsFilePathForSource(source) === normalizedPath,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for MDM settings changes (registry/plist).
|
||||
* Takes a snapshot of current MDM settings and compares on each tick.
|
||||
*/
|
||||
function startMdmPoll(): void {
|
||||
// Capture initial snapshot (includes both admin MDM and user-writable HKCU)
|
||||
const initial = getMdmSettings()
|
||||
const initialHkcu = getHkcuSettings()
|
||||
lastMdmSnapshot = jsonStringify({
|
||||
mdm: initial.settings,
|
||||
hkcu: initialHkcu.settings,
|
||||
})
|
||||
|
||||
mdmPollTimer = setInterval(() => {
|
||||
if (disposed) return
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { mdm: current, hkcu: currentHkcu } = await refreshMdmSettings()
|
||||
if (disposed) return
|
||||
|
||||
const currentSnapshot = jsonStringify({
|
||||
mdm: current.settings,
|
||||
hkcu: currentHkcu.settings,
|
||||
})
|
||||
|
||||
if (currentSnapshot !== lastMdmSnapshot) {
|
||||
lastMdmSnapshot = currentSnapshot
|
||||
// Update the cache so sync readers pick up new values
|
||||
setMdmSettingsCache(current, currentHkcu)
|
||||
logForDebugging('Detected MDM settings change via poll')
|
||||
fanOut('policySettings')
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(`MDM poll error: ${errorMessage(error)}`)
|
||||
}
|
||||
})()
|
||||
}, testOverrides?.mdmPollInterval ?? MDM_POLL_INTERVAL_MS)
|
||||
|
||||
// Don't let the timer keep the process alive
|
||||
mdmPollTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the settings cache, then notify all listeners.
|
||||
*
|
||||
* The cache reset MUST happen here (single producer), not in each listener
|
||||
* (N consumers). Previously, listeners like useSettingsChange and
|
||||
* applySettingsChange reset defensively because some notification paths
|
||||
* (file-watch at :289/340, MDM poll at :385) did not reset before iterating
|
||||
* listeners. That defense caused N-way thrashing when N listeners were
|
||||
* subscribed: each listener cleared the cache, re-read from disk (populating
|
||||
* it), then the next listener cleared it again — N full disk reloads per
|
||||
* notification. Profile showed 5 loadSettingsFromDisk calls in 12ms when
|
||||
* remote managed settings resolved at startup.
|
||||
*
|
||||
* With the reset centralized here, one notification = one disk reload: the
|
||||
* first listener to call getSettingsWithErrors() pays the miss and
|
||||
* repopulates; all subsequent listeners hit the cache.
|
||||
*/
|
||||
function fanOut(source: SettingSource): void {
|
||||
resetSettingsCache()
|
||||
settingsChanged.emit(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually notify listeners of a settings change.
|
||||
* Used for programmatic settings changes (e.g., remote managed settings refresh)
|
||||
* that don't involve file system changes.
|
||||
*/
|
||||
export function notifyChange(source: SettingSource): void {
|
||||
logForDebugging(`Programmatic settings change notification for ${source}`)
|
||||
fanOut(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state for testing purposes only.
|
||||
* This allows re-initialization after dispose().
|
||||
* Optionally accepts timing overrides for faster test execution.
|
||||
*
|
||||
* Closes the watcher and returns the close promise so preload's afterEach
|
||||
* can await it BEFORE nuking perTestSettingsDir. Without this, chokidar's
|
||||
* pending awaitWriteFinish poll fires on the deleted dir → ENOENT (#25253).
|
||||
*/
|
||||
export function resetForTesting(overrides?: {
|
||||
stabilityThreshold?: number
|
||||
pollInterval?: number
|
||||
mdmPollInterval?: number
|
||||
deletionGrace?: number
|
||||
}): Promise<void> {
|
||||
if (mdmPollTimer) {
|
||||
clearInterval(mdmPollTimer)
|
||||
mdmPollTimer = null
|
||||
}
|
||||
for (const timer of pendingDeletions.values()) clearTimeout(timer)
|
||||
pendingDeletions.clear()
|
||||
lastMdmSnapshot = null
|
||||
initialized = false
|
||||
disposed = false
|
||||
testOverrides = overrides ?? null
|
||||
const w = watcher
|
||||
watcher = null
|
||||
return w ? w.close() : Promise.resolve()
|
||||
}
|
||||
|
||||
export const settingsChangeDetector = {
|
||||
initialize,
|
||||
dispose,
|
||||
subscribe,
|
||||
notifyChange,
|
||||
resetForTesting,
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { getAllowedSettingSources } from '../../bootstrap/state.js'
|
||||
|
||||
/**
|
||||
* All possible sources where settings can come from
|
||||
* Order matters - later sources override earlier ones
|
||||
*/
|
||||
export const SETTING_SOURCES = [
|
||||
// User settings (global)
|
||||
'userSettings',
|
||||
|
||||
// Project settings (shared per-directory)
|
||||
'projectSettings',
|
||||
|
||||
// Local settings (gitignored)
|
||||
'localSettings',
|
||||
|
||||
// Flag settings (from --settings flag)
|
||||
'flagSettings',
|
||||
|
||||
// Policy settings (managed-settings.json or remote settings from API)
|
||||
'policySettings',
|
||||
] as const
|
||||
|
||||
export type SettingSource = (typeof SETTING_SOURCES)[number]
|
||||
|
||||
export function getSettingSourceName(source: SettingSource): string {
|
||||
switch (source) {
|
||||
case 'userSettings':
|
||||
return 'user'
|
||||
case 'projectSettings':
|
||||
return 'project'
|
||||
case 'localSettings':
|
||||
return 'project, gitignored'
|
||||
case 'flagSettings':
|
||||
return 'cli flag'
|
||||
case 'policySettings':
|
||||
return 'managed'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short display name for a setting source (capitalized, for context/skills UI)
|
||||
* @param source The setting source or 'plugin'/'built-in'
|
||||
* @returns Short capitalized display name like 'User', 'Project', 'Plugin'
|
||||
*/
|
||||
export function getSourceDisplayName(
|
||||
source: SettingSource | 'plugin' | 'built-in',
|
||||
): string {
|
||||
switch (source) {
|
||||
case 'userSettings':
|
||||
return 'User'
|
||||
case 'projectSettings':
|
||||
return 'Project'
|
||||
case 'localSettings':
|
||||
return 'Local'
|
||||
case 'flagSettings':
|
||||
return 'Flag'
|
||||
case 'policySettings':
|
||||
return 'Managed'
|
||||
case 'plugin':
|
||||
return 'Plugin'
|
||||
case 'built-in':
|
||||
return 'Built-in'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a setting or permission rule source (lowercase, for inline use)
|
||||
* @param source The setting source or permission rule source
|
||||
* @returns Display name for the source in lowercase
|
||||
*/
|
||||
export function getSettingSourceDisplayNameLowercase(
|
||||
source: SettingSource | 'cliArg' | 'command' | 'session',
|
||||
): string {
|
||||
switch (source) {
|
||||
case 'userSettings':
|
||||
return 'user settings'
|
||||
case 'projectSettings':
|
||||
return 'shared project settings'
|
||||
case 'localSettings':
|
||||
return 'project local settings'
|
||||
case 'flagSettings':
|
||||
return 'command line arguments'
|
||||
case 'policySettings':
|
||||
return 'enterprise managed settings'
|
||||
case 'cliArg':
|
||||
return 'CLI argument'
|
||||
case 'command':
|
||||
return 'command configuration'
|
||||
case 'session':
|
||||
return 'current session'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a setting or permission rule source (capitalized, for UI labels)
|
||||
* @param source The setting source or permission rule source
|
||||
* @returns Display name for the source with first letter capitalized
|
||||
*/
|
||||
export function getSettingSourceDisplayNameCapitalized(
|
||||
source: SettingSource | 'cliArg' | 'command' | 'session',
|
||||
): string {
|
||||
switch (source) {
|
||||
case 'userSettings':
|
||||
return 'User settings'
|
||||
case 'projectSettings':
|
||||
return 'Shared project settings'
|
||||
case 'localSettings':
|
||||
return 'Project local settings'
|
||||
case 'flagSettings':
|
||||
return 'Command line arguments'
|
||||
case 'policySettings':
|
||||
return 'Enterprise managed settings'
|
||||
case 'cliArg':
|
||||
return 'CLI argument'
|
||||
case 'command':
|
||||
return 'Command configuration'
|
||||
case 'session':
|
||||
return 'Current session'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the --setting-sources CLI flag into SettingSource array
|
||||
* @param flag Comma-separated string like "user,project,local"
|
||||
* @returns Array of SettingSource values
|
||||
*/
|
||||
export function parseSettingSourcesFlag(flag: string): SettingSource[] {
|
||||
if (flag === '') return []
|
||||
|
||||
const names = flag.split(',').map(s => s.trim())
|
||||
const result: SettingSource[] = []
|
||||
|
||||
for (const name of names) {
|
||||
switch (name) {
|
||||
case 'user':
|
||||
result.push('userSettings')
|
||||
break
|
||||
case 'project':
|
||||
result.push('projectSettings')
|
||||
break
|
||||
case 'local':
|
||||
result.push('localSettings')
|
||||
break
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid setting source: ${name}. Valid options are: user, project, local`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled setting sources with policy/flag always included
|
||||
* @returns Array of enabled SettingSource values
|
||||
*/
|
||||
export function getEnabledSettingSources(): SettingSource[] {
|
||||
const allowed = getAllowedSettingSources()
|
||||
|
||||
// Always include policy and flag settings
|
||||
const result = new Set<SettingSource>(allowed)
|
||||
result.add('policySettings')
|
||||
result.add('flagSettings')
|
||||
return Array.from(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific source is enabled
|
||||
* @param source The source to check
|
||||
* @returns true if the source should be loaded
|
||||
*/
|
||||
export function isSettingSourceEnabled(source: SettingSource): boolean {
|
||||
const enabled = getEnabledSettingSources()
|
||||
return enabled.includes(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable setting sources (excludes policySettings and flagSettings which are read-only)
|
||||
*/
|
||||
export type EditableSettingSource = Exclude<
|
||||
SettingSource,
|
||||
'policySettings' | 'flagSettings'
|
||||
>
|
||||
|
||||
/**
|
||||
* List of sources where permission rules can be saved, in display order.
|
||||
* Used by permission-rule and hook-save UIs to present source options.
|
||||
*/
|
||||
export const SOURCES = [
|
||||
'localSettings',
|
||||
'projectSettings',
|
||||
'userSettings',
|
||||
] as const satisfies readonly EditableSettingSource[]
|
||||
|
||||
/**
|
||||
* The JSON Schema URL for Claude Code settings
|
||||
* You can edit the contents at https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/claude-code-settings.json
|
||||
*/
|
||||
export const CLAUDE_CODE_SETTINGS_SCHEMA_URL =
|
||||
'https://json.schemastore.org/claude-code-settings.json'
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Tracks timestamps of in-process settings-file writes so the chokidar watcher
|
||||
* in changeDetector.ts can ignore its own echoes.
|
||||
*
|
||||
* Extracted from changeDetector.ts to break the settings.ts → changeDetector.ts →
|
||||
* hooks.ts → … → settings.ts cycle. settings.ts needs to mark "I'm about to
|
||||
* write" before the write lands; changeDetector needs to read the mark when
|
||||
* chokidar fires. The map is the only shared state — everything else in
|
||||
* changeDetector (chokidar, hooks, mdm polling) is irrelevant to settings.ts.
|
||||
*
|
||||
* Callers pass resolved paths. The path→source resolution (getSettingsFilePathForSource)
|
||||
* lives in settings.ts, so settings.ts does it before calling here. No imports.
|
||||
*/
|
||||
|
||||
const timestamps = new Map<string, number>()
|
||||
|
||||
export function markInternalWrite(path: string): void {
|
||||
timestamps.set(path, Date.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `path` was marked within `windowMs`. Consumes the mark on match —
|
||||
* the watcher fires once per write, so a matched mark shouldn't suppress
|
||||
* the next (real, external) change to the same file.
|
||||
*/
|
||||
export function consumeInternalWrite(path: string, windowMs: number): boolean {
|
||||
const ts = timestamps.get(path)
|
||||
if (ts !== undefined && Date.now() - ts < windowMs) {
|
||||
timestamps.delete(path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function clearInternalWrites(): void {
|
||||
timestamps.clear()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { join } from 'path'
|
||||
import { getPlatform } from '../platform.js'
|
||||
|
||||
/**
|
||||
* Get the path to the managed settings directory based on the current platform.
|
||||
*/
|
||||
export const getManagedFilePath = memoize(function (): string {
|
||||
// Allow override for testing/demos (Ant-only, eliminated from external builds)
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_CODE_MANAGED_SETTINGS_PATH
|
||||
) {
|
||||
return process.env.CLAUDE_CODE_MANAGED_SETTINGS_PATH
|
||||
}
|
||||
|
||||
switch (getPlatform()) {
|
||||
case 'macos':
|
||||
return '/Library/Application Support/ClaudeCode'
|
||||
case 'windows':
|
||||
return 'C:\\Program Files\\ClaudeCode'
|
||||
default:
|
||||
return '/etc/claude-code'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the path to the managed-settings.d/ drop-in directory.
|
||||
* managed-settings.json is merged first (base), then files in this directory
|
||||
* are merged alphabetically on top (drop-ins override base, later files win).
|
||||
*/
|
||||
export const getManagedSettingsDropInDir = memoize(function (): string {
|
||||
return join(getManagedFilePath(), 'managed-settings.d')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { z } from 'zod/v4'
|
||||
import { mcpInfoFromString } from '../../services/mcp/mcpStringUtils.js'
|
||||
import { lazySchema } from '../lazySchema.js'
|
||||
import { permissionRuleValueFromString } from '../permissions/permissionRuleParser.js'
|
||||
import { capitalize } from '../stringUtils.js'
|
||||
import {
|
||||
getCustomValidation,
|
||||
isBashPrefixTool,
|
||||
isFilePatternTool,
|
||||
} from './toolValidationConfig.js'
|
||||
|
||||
/**
|
||||
* Checks if a character at a given index is escaped (preceded by odd number of backslashes).
|
||||
*/
|
||||
function isEscaped(str: string, index: number): boolean {
|
||||
let backslashCount = 0
|
||||
let j = index - 1
|
||||
while (j >= 0 && str[j] === '\\') {
|
||||
backslashCount++
|
||||
j--
|
||||
}
|
||||
return backslashCount % 2 !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts unescaped occurrences of a character in a string.
|
||||
* A character is considered escaped if preceded by an odd number of backslashes.
|
||||
*/
|
||||
function countUnescapedChar(str: string, char: string): number {
|
||||
let count = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (str[i] === char && !isEscaped(str, i)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string contains unescaped empty parentheses "()".
|
||||
* Returns true only if both the "(" and ")" are unescaped and adjacent.
|
||||
*/
|
||||
function hasUnescapedEmptyParens(str: string): boolean {
|
||||
for (let i = 0; i < str.length - 1; i++) {
|
||||
if (str[i] === '(' && str[i + 1] === ')') {
|
||||
// Check if the opening paren is unescaped
|
||||
if (!isEscaped(str, i)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates permission rule format and content
|
||||
*/
|
||||
export function validatePermissionRule(rule: string): {
|
||||
valid: boolean
|
||||
error?: string
|
||||
suggestion?: string
|
||||
examples?: string[]
|
||||
} {
|
||||
// Empty rule check
|
||||
if (!rule || rule.trim() === '') {
|
||||
return { valid: false, error: 'Permission rule cannot be empty' }
|
||||
}
|
||||
|
||||
// Check parentheses matching first (only count unescaped parens)
|
||||
const openCount = countUnescapedChar(rule, '(')
|
||||
const closeCount = countUnescapedChar(rule, ')')
|
||||
if (openCount !== closeCount) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Mismatched parentheses',
|
||||
suggestion:
|
||||
'Ensure all opening parentheses have matching closing parentheses',
|
||||
}
|
||||
}
|
||||
|
||||
// Check for empty parentheses (escape-aware)
|
||||
if (hasUnescapedEmptyParens(rule)) {
|
||||
const toolName = rule.substring(0, rule.indexOf('('))
|
||||
if (!toolName) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Empty parentheses with no tool name',
|
||||
suggestion: 'Specify a tool name before the parentheses',
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Empty parentheses',
|
||||
suggestion: `Either specify a pattern or use just "${toolName}" without parentheses`,
|
||||
examples: [`${toolName}`, `${toolName}(some-pattern)`],
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the rule
|
||||
const parsed = permissionRuleValueFromString(rule)
|
||||
|
||||
// MCP validation - must be done before general tool validation
|
||||
const mcpInfo = mcpInfoFromString(parsed.toolName)
|
||||
if (mcpInfo) {
|
||||
// MCP rules support server-level, tool-level, and wildcard permissions
|
||||
// Valid formats:
|
||||
// - mcp__server (server-level, all tools)
|
||||
// - mcp__server__* (wildcard, all tools - equivalent to server-level)
|
||||
// - mcp__server__tool (specific tool)
|
||||
|
||||
// MCP rules cannot have any pattern/content (parentheses)
|
||||
// Check both parsed content and raw string since the parser normalizes
|
||||
// standalone wildcards (e.g., "mcp__server(*)") to undefined ruleContent
|
||||
if (parsed.ruleContent !== undefined || countUnescapedChar(rule, '(') > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'MCP rules do not support patterns in parentheses',
|
||||
suggestion: `Use "${parsed.toolName}" without parentheses, or use "mcp__${mcpInfo.serverName}__*" for all tools`,
|
||||
examples: [
|
||||
`mcp__${mcpInfo.serverName}`,
|
||||
`mcp__${mcpInfo.serverName}__*`,
|
||||
mcpInfo.toolName && mcpInfo.toolName !== '*'
|
||||
? `mcp__${mcpInfo.serverName}__${mcpInfo.toolName}`
|
||||
: undefined,
|
||||
].filter(Boolean) as string[],
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true } // Valid MCP rule
|
||||
}
|
||||
|
||||
// Tool name validation (for non-MCP tools)
|
||||
if (!parsed.toolName || parsed.toolName.length === 0) {
|
||||
return { valid: false, error: 'Tool name cannot be empty' }
|
||||
}
|
||||
|
||||
// Check tool name starts with uppercase (standard tools)
|
||||
if (parsed.toolName[0] !== parsed.toolName[0]?.toUpperCase()) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Tool names must start with uppercase',
|
||||
suggestion: `Use "${capitalize(String(parsed.toolName))}"`,
|
||||
}
|
||||
}
|
||||
|
||||
// Check for custom validation rules first
|
||||
const customValidation = getCustomValidation(parsed.toolName)
|
||||
if (customValidation && parsed.ruleContent !== undefined) {
|
||||
const customResult = customValidation(parsed.ruleContent)
|
||||
if (!customResult.valid) {
|
||||
return customResult
|
||||
}
|
||||
}
|
||||
|
||||
// Bash-specific validation
|
||||
if (isBashPrefixTool(parsed.toolName) && parsed.ruleContent !== undefined) {
|
||||
const content = parsed.ruleContent
|
||||
|
||||
// Check for common :* mistakes - :* must be at the end (legacy prefix syntax)
|
||||
if (content.includes(':*') && !content.endsWith(':*')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'The :* pattern must be at the end',
|
||||
suggestion:
|
||||
'Move :* to the end for prefix matching, or use * for wildcard matching',
|
||||
examples: [
|
||||
'Bash(npm run:*) - prefix matching (legacy)',
|
||||
'Bash(npm run *) - wildcard matching',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Check for :* without a prefix
|
||||
if (content === ':*') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Prefix cannot be empty before :*',
|
||||
suggestion: 'Specify a command prefix before :*',
|
||||
examples: ['Bash(npm:*)', 'Bash(git:*)'],
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We don't validate quote balancing because bash quoting rules are complex.
|
||||
// A command like `grep '"'` has valid unbalanced double quotes.
|
||||
// Users who create patterns with unintended quote mismatches will discover
|
||||
// the issue when matching doesn't work as expected.
|
||||
|
||||
// Wildcards are now allowed at any position for flexible pattern matching
|
||||
// Examples of valid wildcard patterns:
|
||||
// - "npm *" matches "npm install", "npm run test", etc.
|
||||
// - "* install" matches "npm install", "yarn install", etc.
|
||||
// - "git * main" matches "git checkout main", "git push main", etc.
|
||||
// - "npm * --save" matches "npm install foo --save", etc.
|
||||
//
|
||||
// Legacy :* syntax continues to work for backwards compatibility:
|
||||
// - "npm:*" matches "npm" or "npm <anything>" (prefix matching with word boundary)
|
||||
}
|
||||
|
||||
// File tool validation
|
||||
if (isFilePatternTool(parsed.toolName) && parsed.ruleContent !== undefined) {
|
||||
const content = parsed.ruleContent
|
||||
|
||||
// Check for :* in file patterns (common mistake from Bash patterns)
|
||||
if (content.includes(':*')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'The ":*" syntax is only for Bash prefix rules',
|
||||
suggestion: 'Use glob patterns like "*" or "**" for file matching',
|
||||
examples: [
|
||||
`${parsed.toolName}(*.ts) - matches .ts files`,
|
||||
`${parsed.toolName}(src/**) - matches all files in src`,
|
||||
`${parsed.toolName}(**/*.test.ts) - matches test files`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Warn about wildcards not at boundaries
|
||||
if (
|
||||
content.includes('*') &&
|
||||
!content.match(/^\*|\*$|\*\*|\/\*|\*\.|\*\)/) &&
|
||||
!content.includes('**')
|
||||
) {
|
||||
// This is a loose check - wildcards in the middle might be valid in some cases
|
||||
// but often indicate confusion
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Wildcard placement might be incorrect',
|
||||
suggestion: 'Wildcards are typically used at path boundaries',
|
||||
examples: [
|
||||
`${parsed.toolName}(*.js) - all .js files`,
|
||||
`${parsed.toolName}(src/*) - all files directly in src`,
|
||||
`${parsed.toolName}(src/**) - all files recursively in src`,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Zod schema for permission rule arrays
|
||||
*/
|
||||
export const PermissionRuleSchema = lazySchema(() =>
|
||||
z.string().superRefine((val, ctx) => {
|
||||
const result = validatePermissionRule(val)
|
||||
if (!result.valid) {
|
||||
let message = result.error!
|
||||
if (result.suggestion) {
|
||||
message += `. ${result.suggestion}`
|
||||
}
|
||||
if (result.examples && result.examples.length > 0) {
|
||||
message += `. Examples: ${result.examples.join(', ')}`
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message,
|
||||
params: { received: val },
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getSettingsForSource } from './settings.js'
|
||||
import type { CUSTOMIZATION_SURFACES } from './types.js'
|
||||
|
||||
export type CustomizationSurface = (typeof CUSTOMIZATION_SURFACES)[number]
|
||||
|
||||
/**
|
||||
* Check whether a customization surface is locked to plugin-only sources
|
||||
* by the managed `strictPluginOnlyCustomization` policy.
|
||||
*
|
||||
* "Locked" means user-level (~/.claude/*) and project-level (.claude/*)
|
||||
* sources are skipped for that surface. Managed (policySettings) and
|
||||
* plugin-provided sources always load regardless — the policy is admin-set,
|
||||
* so managed sources are already admin-controlled, and plugins are gated
|
||||
* separately via `strictKnownMarketplaces`.
|
||||
*
|
||||
* `true` locks all four surfaces; array form locks only those listed.
|
||||
* Absent/undefined → nothing locked (the default).
|
||||
*/
|
||||
export function isRestrictedToPluginOnly(
|
||||
surface: CustomizationSurface,
|
||||
): boolean {
|
||||
const policy =
|
||||
getSettingsForSource('policySettings')?.strictPluginOnlyCustomization
|
||||
if (policy === true) return true
|
||||
if (Array.isArray(policy)) return policy.includes(surface)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources that bypass strictPluginOnlyCustomization. Admin-trusted because:
|
||||
* plugin — gated separately by strictKnownMarketplaces
|
||||
* policySettings — from managed settings, admin-controlled by definition
|
||||
* built-in / builtin / bundled — ship with the CLI, not user-authored
|
||||
*
|
||||
* Everything else (userSettings, projectSettings, localSettings, flagSettings,
|
||||
* mcp, undefined) is user-controlled and blocked when the relevant surface
|
||||
* is locked. Covers both AgentDefinition.source ('built-in' with hyphen) and
|
||||
* Command.source ('builtin' no hyphen, plus 'bundled').
|
||||
*/
|
||||
const ADMIN_TRUSTED_SOURCES: ReadonlySet<string> = new Set([
|
||||
'plugin',
|
||||
'policySettings',
|
||||
'built-in',
|
||||
'builtin',
|
||||
'bundled',
|
||||
])
|
||||
|
||||
/**
|
||||
* Whether a customization's source is admin-trusted under
|
||||
* strictPluginOnlyCustomization. Use this to gate frontmatter-hook
|
||||
* registration and similar per-item checks where the item carries a
|
||||
* source tag but the surface's filesystem loader already ran.
|
||||
*
|
||||
* Pattern at call sites:
|
||||
* const allowed = !isRestrictedToPluginOnly(surface) || isSourceAdminTrusted(item.source)
|
||||
* if (item.hooks && allowed) { register(...) }
|
||||
*/
|
||||
export function isSourceAdminTrusted(source: string | undefined): boolean {
|
||||
return source !== undefined && ADMIN_TRUSTED_SOURCES.has(source)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { toJSONSchema } from 'zod/v4'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import { SettingsSchema } from './types.js'
|
||||
|
||||
export function generateSettingsJSONSchema(): string {
|
||||
const jsonSchema = toJSONSchema(SettingsSchema(), { unrepresentable: 'any' })
|
||||
return jsonStringify(jsonSchema, null, 2)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
import type { SettingSource } from './constants.js'
|
||||
import type { SettingsJson } from './types.js'
|
||||
import type { SettingsWithErrors, ValidationError } from './validation.js'
|
||||
|
||||
let sessionSettingsCache: SettingsWithErrors | null = null
|
||||
|
||||
export function getSessionSettingsCache(): SettingsWithErrors | null {
|
||||
return sessionSettingsCache
|
||||
}
|
||||
|
||||
export function setSessionSettingsCache(value: SettingsWithErrors): void {
|
||||
sessionSettingsCache = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-source cache for getSettingsForSource. Invalidated alongside the
|
||||
* merged sessionSettingsCache — same resetSettingsCache() triggers
|
||||
* (settings write, --add-dir, plugin init, hooks refresh).
|
||||
*/
|
||||
const perSourceCache = new Map<SettingSource, SettingsJson | null>()
|
||||
|
||||
export function getCachedSettingsForSource(
|
||||
source: SettingSource,
|
||||
): SettingsJson | null | undefined {
|
||||
// undefined = cache miss; null = cached "no settings for this source"
|
||||
return perSourceCache.has(source) ? perSourceCache.get(source) : undefined
|
||||
}
|
||||
|
||||
export function setCachedSettingsForSource(
|
||||
source: SettingSource,
|
||||
value: SettingsJson | null,
|
||||
): void {
|
||||
perSourceCache.set(source, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Path-keyed cache for parseSettingsFile. Both getSettingsForSource and
|
||||
* loadSettingsFromDisk call parseSettingsFile on the same paths during
|
||||
* startup — this dedupes the disk read + zod parse.
|
||||
*/
|
||||
type ParsedSettings = {
|
||||
settings: SettingsJson | null
|
||||
errors: ValidationError[]
|
||||
}
|
||||
const parseFileCache = new Map<string, ParsedSettings>()
|
||||
|
||||
export function getCachedParsedFile(path: string): ParsedSettings | undefined {
|
||||
return parseFileCache.get(path)
|
||||
}
|
||||
|
||||
export function setCachedParsedFile(path: string, value: ParsedSettings): void {
|
||||
parseFileCache.set(path, value)
|
||||
}
|
||||
|
||||
export function resetSettingsCache(): void {
|
||||
sessionSettingsCache = null
|
||||
perSourceCache.clear()
|
||||
parseFileCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin settings base layer for the settings cascade.
|
||||
* pluginLoader writes here after loading plugins;
|
||||
* loadSettingsFromDisk reads it as the lowest-priority base.
|
||||
*/
|
||||
let pluginSettingsBase: Record<string, unknown> | undefined
|
||||
|
||||
export function getPluginSettingsBase(): Record<string, unknown> | undefined {
|
||||
return pluginSettingsBase
|
||||
}
|
||||
|
||||
export function setPluginSettingsBase(
|
||||
settings: Record<string, unknown> | undefined,
|
||||
): void {
|
||||
pluginSettingsBase = settings
|
||||
}
|
||||
|
||||
export function clearPluginSettingsBase(): void {
|
||||
pluginSettingsBase = undefined
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Tool validation configuration
|
||||
*
|
||||
* Most tools need NO configuration - basic validation works automatically.
|
||||
* Only add your tool here if it has special pattern requirements.
|
||||
*/
|
||||
|
||||
export type ToolValidationConfig = {
|
||||
/** Tools that accept file glob patterns (e.g., *.ts, src/**) */
|
||||
filePatternTools: string[]
|
||||
|
||||
/** Tools that accept bash wildcard patterns (* anywhere) and legacy :* prefix syntax */
|
||||
bashPrefixTools: string[]
|
||||
|
||||
/** Custom validation rules for specific tools */
|
||||
customValidation: {
|
||||
[toolName: string]: (content: string) => {
|
||||
valid: boolean
|
||||
error?: string
|
||||
suggestion?: string
|
||||
examples?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const TOOL_VALIDATION_CONFIG: ToolValidationConfig = {
|
||||
// File pattern tools (accept *.ts, src/**, etc.)
|
||||
filePatternTools: [
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'Glob',
|
||||
'NotebookRead',
|
||||
'NotebookEdit',
|
||||
],
|
||||
|
||||
// Bash wildcard tools (accept * anywhere, and legacy command:* syntax)
|
||||
bashPrefixTools: ['Bash'],
|
||||
|
||||
// Custom validation (only if needed)
|
||||
customValidation: {
|
||||
// WebSearch doesn't support wildcards or complex patterns
|
||||
WebSearch: content => {
|
||||
if (content.includes('*') || content.includes('?')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'WebSearch does not support wildcards',
|
||||
suggestion: 'Use exact search terms without * or ?',
|
||||
examples: ['WebSearch(claude ai)', 'WebSearch(typescript tutorial)'],
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
},
|
||||
|
||||
// WebFetch uses domain: prefix for hostname-based permissions
|
||||
WebFetch: content => {
|
||||
// Check if it's trying to use a URL format
|
||||
if (content.includes('://') || content.startsWith('http')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'WebFetch permissions use domain format, not URLs',
|
||||
suggestion: 'Use "domain:hostname" format',
|
||||
examples: [
|
||||
'WebFetch(domain:example.com)',
|
||||
'WebFetch(domain:github.com)',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Must start with domain: prefix
|
||||
if (!content.startsWith('domain:')) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'WebFetch permissions must use "domain:" prefix',
|
||||
suggestion: 'Use "domain:hostname" format',
|
||||
examples: [
|
||||
'WebFetch(domain:example.com)',
|
||||
'WebFetch(domain:*.google.com)',
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Allow wildcards in domain patterns
|
||||
// Valid: domain:*.example.com, domain:example.*, etc.
|
||||
return { valid: true }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Helper to check if a tool uses file patterns
|
||||
export function isFilePatternTool(toolName: string): boolean {
|
||||
return TOOL_VALIDATION_CONFIG.filePatternTools.includes(toolName)
|
||||
}
|
||||
|
||||
// Helper to check if a tool uses bash prefix patterns
|
||||
export function isBashPrefixTool(toolName: string): boolean {
|
||||
return TOOL_VALIDATION_CONFIG.bashPrefixTools.includes(toolName)
|
||||
}
|
||||
|
||||
// Helper to get custom validation for a tool
|
||||
export function getCustomValidation(toolName: string) {
|
||||
return TOOL_VALIDATION_CONFIG.customValidation[toolName]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import type { ValidationResult } from 'src/Tool.js'
|
||||
import { isClaudeSettingsPath } from '../permissions/filesystem.js'
|
||||
import { validateSettingsFileContent } from './validation.js'
|
||||
|
||||
/**
|
||||
* Validates settings file edits to ensure the result conforms to SettingsSchema.
|
||||
* This is used by FileEditTool to avoid code duplication.
|
||||
*
|
||||
* @param filePath - The file path being edited
|
||||
* @param originalContent - The original file content before edits
|
||||
* @param getUpdatedContent - A closure that returns the content after applying edits
|
||||
* @returns Validation result with error details if validation fails
|
||||
*/
|
||||
export function validateInputForSettingsFileEdit(
|
||||
filePath: string,
|
||||
originalContent: string,
|
||||
getUpdatedContent: () => string,
|
||||
): Extract<ValidationResult, { result: false }> | null {
|
||||
// Only validate Claude settings files
|
||||
if (!isClaudeSettingsPath(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if the current file (before edit) conforms to the schema
|
||||
const beforeValidation = validateSettingsFileContent(originalContent)
|
||||
|
||||
if (!beforeValidation.isValid) {
|
||||
// If the before version is invalid, allow the edit (don't block it)
|
||||
return null
|
||||
}
|
||||
|
||||
// If the before version is valid, ensure the after version is also valid
|
||||
const updatedContent = getUpdatedContent()
|
||||
const afterValidation = validateSettingsFileContent(updatedContent)
|
||||
|
||||
if (!afterValidation.isValid) {
|
||||
return {
|
||||
result: false,
|
||||
message: `Claude Code settings.json validation failed after edit:\n${afterValidation.error}\n\nFull schema:\n${afterValidation.fullSchema}\nIMPORTANT: Do not update the env unless explicitly instructed to do so.`,
|
||||
errorCode: 10,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { ConfigScope } from 'src/services/mcp/types.js'
|
||||
import type { ZodError, ZodIssue } from 'zod/v4'
|
||||
import { jsonParse } from '../slowOperations.js'
|
||||
import { plural } from '../stringUtils.js'
|
||||
import { validatePermissionRule } from './permissionValidation.js'
|
||||
import { generateSettingsJSONSchema } from './schemaOutput.js'
|
||||
import type { SettingsJson } from './types.js'
|
||||
import { SettingsSchema } from './types.js'
|
||||
import { getValidationTip } from './validationTips.js'
|
||||
|
||||
/**
|
||||
* Helper type guards for specific Zod v4 issue types
|
||||
* In v4, issue types have different structures than v3
|
||||
*/
|
||||
function isInvalidTypeIssue(issue: ZodIssue): issue is ZodIssue & {
|
||||
code: 'invalid_type'
|
||||
expected: string
|
||||
input: unknown
|
||||
} {
|
||||
return issue.code === 'invalid_type'
|
||||
}
|
||||
|
||||
function isInvalidValueIssue(issue: ZodIssue): issue is ZodIssue & {
|
||||
code: 'invalid_value'
|
||||
values: unknown[]
|
||||
input: unknown
|
||||
} {
|
||||
return issue.code === 'invalid_value'
|
||||
}
|
||||
|
||||
function isUnrecognizedKeysIssue(
|
||||
issue: ZodIssue,
|
||||
): issue is ZodIssue & { code: 'unrecognized_keys'; keys: string[] } {
|
||||
return issue.code === 'unrecognized_keys'
|
||||
}
|
||||
|
||||
function isTooSmallIssue(issue: ZodIssue): issue is ZodIssue & {
|
||||
code: 'too_small'
|
||||
minimum: number | bigint
|
||||
origin: string
|
||||
} {
|
||||
return issue.code === 'too_small'
|
||||
}
|
||||
|
||||
/** Field path in dot notation (e.g., "permissions.defaultMode", "env.DEBUG") */
|
||||
export type FieldPath = string
|
||||
|
||||
export type ValidationError = {
|
||||
/** Relative file path */
|
||||
file?: string
|
||||
/** Field path in dot notation */
|
||||
path: FieldPath
|
||||
/** Human-readable error message */
|
||||
message: string
|
||||
/** Expected value or type */
|
||||
expected?: string
|
||||
/** The actual invalid value that was provided */
|
||||
invalidValue?: unknown
|
||||
/** Suggestion for fixing the error */
|
||||
suggestion?: string
|
||||
/** Link to relevant documentation */
|
||||
docLink?: string
|
||||
/** MCP-specific metadata - only present for MCP configuration errors */
|
||||
mcpErrorMetadata?: {
|
||||
/** Which configuration scope this error came from */
|
||||
scope: ConfigScope
|
||||
/** The server name if error is specific to a server */
|
||||
serverName?: string
|
||||
/** Severity of the error */
|
||||
severity?: 'fatal' | 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
export type SettingsWithErrors = {
|
||||
settings: SettingsJson
|
||||
errors: ValidationError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Zod validation error into human-readable validation errors
|
||||
*/
|
||||
/**
|
||||
* Get the type string for an unknown value (for error messages)
|
||||
*/
|
||||
function getReceivedType(value: unknown): string {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
return typeof value
|
||||
}
|
||||
|
||||
function extractReceivedFromMessage(msg: string): string | undefined {
|
||||
const match = msg.match(/received (\w+)/)
|
||||
return match ? match[1] : undefined
|
||||
}
|
||||
|
||||
export function formatZodError(
|
||||
error: ZodError,
|
||||
filePath: string,
|
||||
): ValidationError[] {
|
||||
return error.issues.map((issue): ValidationError => {
|
||||
const path = issue.path.map(String).join('.')
|
||||
let message = issue.message
|
||||
let expected: string | undefined
|
||||
|
||||
let enumValues: string[] | undefined
|
||||
let expectedValue: string | undefined
|
||||
let receivedValue: unknown
|
||||
let invalidValue: unknown
|
||||
|
||||
if (isInvalidValueIssue(issue)) {
|
||||
enumValues = issue.values.map(v => String(v))
|
||||
expectedValue = enumValues.join(' | ')
|
||||
receivedValue = undefined
|
||||
invalidValue = undefined
|
||||
} else if (isInvalidTypeIssue(issue)) {
|
||||
expectedValue = issue.expected
|
||||
const receivedType = extractReceivedFromMessage(issue.message)
|
||||
receivedValue = receivedType ?? getReceivedType(issue.input)
|
||||
invalidValue = receivedType ?? getReceivedType(issue.input)
|
||||
} else if (isTooSmallIssue(issue)) {
|
||||
expectedValue = String(issue.minimum)
|
||||
} else if (issue.code === 'custom' && 'params' in issue) {
|
||||
const params = issue.params as { received?: unknown }
|
||||
receivedValue = params.received
|
||||
invalidValue = receivedValue
|
||||
}
|
||||
|
||||
const tip = getValidationTip({
|
||||
path,
|
||||
code: issue.code,
|
||||
expected: expectedValue,
|
||||
received: receivedValue,
|
||||
enumValues,
|
||||
message: issue.message,
|
||||
value: receivedValue,
|
||||
})
|
||||
|
||||
if (isInvalidValueIssue(issue)) {
|
||||
expected = enumValues?.map(v => `"${v}"`).join(', ')
|
||||
message = `Invalid value. Expected one of: ${expected}`
|
||||
} else if (isInvalidTypeIssue(issue)) {
|
||||
const receivedType =
|
||||
extractReceivedFromMessage(issue.message) ??
|
||||
getReceivedType(issue.input)
|
||||
if (
|
||||
issue.expected === 'object' &&
|
||||
receivedType === 'null' &&
|
||||
path === ''
|
||||
) {
|
||||
message = 'Invalid or malformed JSON'
|
||||
} else {
|
||||
message = `Expected ${issue.expected}, but received ${receivedType}`
|
||||
}
|
||||
} else if (isUnrecognizedKeysIssue(issue)) {
|
||||
const keys = issue.keys.join(', ')
|
||||
message = `Unrecognized ${plural(issue.keys.length, 'field')}: ${keys}`
|
||||
} else if (isTooSmallIssue(issue)) {
|
||||
message = `Number must be greater than or equal to ${issue.minimum}`
|
||||
expected = String(issue.minimum)
|
||||
}
|
||||
|
||||
return {
|
||||
file: filePath,
|
||||
path,
|
||||
message,
|
||||
expected,
|
||||
invalidValue,
|
||||
suggestion: tip?.suggestion,
|
||||
docLink: tip?.docLink,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that settings file content conforms to the SettingsSchema.
|
||||
* This is used during file edits to ensure the resulting file is valid.
|
||||
*/
|
||||
export function validateSettingsFileContent(content: string):
|
||||
| {
|
||||
isValid: true
|
||||
}
|
||||
| {
|
||||
isValid: false
|
||||
error: string
|
||||
fullSchema: string
|
||||
} {
|
||||
try {
|
||||
// Parse the JSON first
|
||||
const jsonData = jsonParse(content)
|
||||
|
||||
// Validate against SettingsSchema in strict mode
|
||||
const result = SettingsSchema().strict().safeParse(jsonData)
|
||||
|
||||
if (result.success) {
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
// Format the validation error in a helpful way
|
||||
const errors = formatZodError(result.error, 'settings')
|
||||
const errorMessage =
|
||||
'Settings validation failed:\n' +
|
||||
errors.map(err => `- ${err.path}: ${err.message}`).join('\n')
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
error: errorMessage,
|
||||
fullSchema: generateSettingsJSONSchema(),
|
||||
}
|
||||
} catch (parseError) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Invalid JSON: ${parseError instanceof Error ? parseError.message : 'Unknown parsing error'}`,
|
||||
fullSchema: generateSettingsJSONSchema(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters invalid permission rules from raw parsed JSON data before schema validation.
|
||||
* This prevents one bad rule from poisoning the entire settings file.
|
||||
* Returns warnings for each filtered rule.
|
||||
*/
|
||||
export function filterInvalidPermissionRules(
|
||||
data: unknown,
|
||||
filePath: string,
|
||||
): ValidationError[] {
|
||||
if (!data || typeof data !== 'object') return []
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!obj.permissions || typeof obj.permissions !== 'object') return []
|
||||
const perms = obj.permissions as Record<string, unknown>
|
||||
|
||||
const warnings: ValidationError[] = []
|
||||
for (const key of ['allow', 'deny', 'ask']) {
|
||||
const rules = perms[key]
|
||||
if (!Array.isArray(rules)) continue
|
||||
|
||||
perms[key] = rules.filter(rule => {
|
||||
if (typeof rule !== 'string') {
|
||||
warnings.push({
|
||||
file: filePath,
|
||||
path: `permissions.${key}`,
|
||||
message: `Non-string value in ${key} array was removed`,
|
||||
invalidValue: rule,
|
||||
})
|
||||
return false
|
||||
}
|
||||
const result = validatePermissionRule(rule)
|
||||
if (!result.valid) {
|
||||
let message = `Invalid permission rule "${rule}" was skipped`
|
||||
if (result.error) message += `: ${result.error}`
|
||||
if (result.suggestion) message += `. ${result.suggestion}`
|
||||
warnings.push({
|
||||
file: filePath,
|
||||
path: `permissions.${key}`,
|
||||
message,
|
||||
invalidValue: rule,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ZodIssueCode } from 'zod/v4'
|
||||
|
||||
// v4 ZodIssueCode is a value, not a type - use typeof to get the type
|
||||
type ZodIssueCodeType = (typeof ZodIssueCode)[keyof typeof ZodIssueCode]
|
||||
|
||||
export type ValidationTip = {
|
||||
suggestion?: string
|
||||
docLink?: string
|
||||
}
|
||||
|
||||
export type TipContext = {
|
||||
path: string
|
||||
code: ZodIssueCodeType | string
|
||||
expected?: string
|
||||
received?: unknown
|
||||
enumValues?: string[]
|
||||
message?: string
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
type TipMatcher = {
|
||||
matches: (context: TipContext) => boolean
|
||||
tip: ValidationTip
|
||||
}
|
||||
|
||||
const DOCUMENTATION_BASE = 'https://code.claude.com/docs/en'
|
||||
|
||||
const TIP_MATCHERS: TipMatcher[] = [
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.path === 'permissions.defaultMode' && ctx.code === 'invalid_value',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Valid modes: "acceptEdits" (ask before file changes), "plan" (analysis only), "bypassPermissions" (auto-accept all), or "default" (standard behavior)',
|
||||
docLink: `${DOCUMENTATION_BASE}/iam#permission-modes`,
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.path === 'apiKeyHelper' && ctx.code === 'invalid_type',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Provide a shell command that outputs your API key to stdout. The script should output only the API key. Example: "/bin/generate_temp_api_key.sh"',
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.path === 'cleanupPeriodDays' &&
|
||||
ctx.code === 'too_small' &&
|
||||
ctx.expected === '0',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Must be 0 or greater. Set a positive number for days to retain transcripts (default is 30). Setting 0 disables session persistence entirely: no transcripts are written and existing transcripts are deleted at startup.',
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.path.startsWith('env.') && ctx.code === 'invalid_type',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Environment variables must be strings. Wrap numbers and booleans in quotes. Example: "DEBUG": "true", "PORT": "3000"',
|
||||
docLink: `${DOCUMENTATION_BASE}/settings#environment-variables`,
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
(ctx.path === 'permissions.allow' || ctx.path === 'permissions.deny') &&
|
||||
ctx.code === 'invalid_type' &&
|
||||
ctx.expected === 'array',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Permission rules must be in an array. Format: ["Tool(specifier)"]. Examples: ["Bash(npm run build)", "Edit(docs/**)", "Read(~/.zshrc)"]. Use * for wildcards.',
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.path.includes('hooks') && ctx.code === 'invalid_type',
|
||||
tip: {
|
||||
suggestion:
|
||||
// gh-31187 / CC-282: prior example showed {"matcher": {"tools": ["BashTool"]}}
|
||||
// — an object format that never existed in the schema (matcher is z.string(),
|
||||
// always has been). Users copied the tip's example and got the same validation
|
||||
// error again. See matchesPattern() in hooks.ts: matcher is exact-match,
|
||||
// pipe-separated ("Edit|Write"), or regex. Empty/"*" matches all.
|
||||
'Hooks use a matcher + hooks array. The matcher is a string: a tool name ("Bash"), pipe-separated list ("Edit|Write"), or empty to match all. Example: {"PostToolUse": [{"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "echo Done"}]}]}',
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.code === 'invalid_type' && ctx.expected === 'boolean',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Use true or false without quotes. Example: "includeCoAuthoredBy": true',
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean => ctx.code === 'unrecognized_keys',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Check for typos or refer to the documentation for valid fields',
|
||||
docLink: `${DOCUMENTATION_BASE}/settings`,
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.code === 'invalid_value' && ctx.enumValues !== undefined,
|
||||
tip: {
|
||||
suggestion: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.code === 'invalid_type' &&
|
||||
ctx.expected === 'object' &&
|
||||
ctx.received === null &&
|
||||
ctx.path === '',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Check for missing commas, unmatched brackets, or trailing commas. Use a JSON validator to identify the exact syntax error.',
|
||||
},
|
||||
},
|
||||
{
|
||||
matches: (ctx): boolean =>
|
||||
ctx.path === 'permissions.additionalDirectories' &&
|
||||
ctx.code === 'invalid_type',
|
||||
tip: {
|
||||
suggestion:
|
||||
'Must be an array of directory paths. Example: ["~/projects", "/tmp/workspace"]. You can also use --add-dir flag or /add-dir command',
|
||||
docLink: `${DOCUMENTATION_BASE}/iam#working-directories`,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const PATH_DOC_LINKS: Record<string, string> = {
|
||||
permissions: `${DOCUMENTATION_BASE}/iam#configuring-permissions`,
|
||||
env: `${DOCUMENTATION_BASE}/settings#environment-variables`,
|
||||
hooks: `${DOCUMENTATION_BASE}/hooks`,
|
||||
}
|
||||
|
||||
export function getValidationTip(context: TipContext): ValidationTip | null {
|
||||
const matcher = TIP_MATCHERS.find(m => m.matches(context))
|
||||
|
||||
if (!matcher) return null
|
||||
|
||||
const tip: ValidationTip = { ...matcher.tip }
|
||||
|
||||
if (
|
||||
context.code === 'invalid_value' &&
|
||||
context.enumValues &&
|
||||
!tip.suggestion
|
||||
) {
|
||||
tip.suggestion = `Valid values: ${context.enumValues.map(v => `"${v}"`).join(', ')}`
|
||||
}
|
||||
|
||||
// Add documentation link based on path prefix
|
||||
if (!tip.docLink && context.path) {
|
||||
const pathPrefix = context.path.split('.')[0]
|
||||
if (pathPrefix) {
|
||||
tip.docLink = PATH_DOC_LINKS[pathPrefix]
|
||||
}
|
||||
}
|
||||
|
||||
return tip
|
||||
}
|
||||
Reference in New Issue
Block a user