init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+32
View File
@@ -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],
}
}
+92
View File
@@ -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 }
: {}),
}
})
}
+488
View File
@@ -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,
}
+202
View File
@@ -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'
+37
View File
@@ -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()
}
+34
View File
@@ -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')
})
+81
View File
@@ -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
}
+130
View File
@@ -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
}
+316
View File
@@ -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
}
+262
View File
@@ -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 },
})
}
}),
)
+60
View File
@@ -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)
}
+8
View File
@@ -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
+80
View File
@@ -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
}
+103
View File
@@ -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
+45
View File
@@ -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
}
+265
View File
@@ -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
}
+164
View File
@@ -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
}