import { feature } from 'bun:bundle' import { z } from 'zod/v4' import { SandboxSettingsSchema } from '../../entrypoints/sandboxTypes.js' import { isEnvTruthy } from '../envUtils.js' import { lazySchema } from '../lazySchema.js' import { EXTERNAL_PERMISSION_MODES, PERMISSION_MODES, } from '../permissions/PermissionMode.js' import { MarketplaceSourceSchema } from '../plugins/schemas.js' import { CLAUDE_CODE_SETTINGS_SCHEMA_URL } from './constants.js' import { PermissionRuleSchema } from './permissionValidation.js' // Re-export hook schemas and types from centralized location for backward compatibility export { type AgentHook, type BashCommandHook, type HookCommand, HookCommandSchema, type HookMatcher, HookMatcherSchema, HooksSchema, type HooksSettings, type HttpHook, type PromptHook, } from '../../schemas/hooks.js' // Also import for use within this file import { type HookCommand, HooksSchema } from '../../schemas/hooks.js' import { count } from '../array.js' /** * Schema for environment variables */ export const EnvironmentVariablesSchema = lazySchema(() => z.record(z.string(), z.coerce.string()), ) /** * Schema for permissions section */ export const PermissionsSchema = lazySchema(() => z .object({ allow: z .array(PermissionRuleSchema()) .optional() .describe('List of permission rules for allowed operations'), deny: z .array(PermissionRuleSchema()) .optional() .describe('List of permission rules for denied operations'), ask: z .array(PermissionRuleSchema()) .optional() .describe( 'List of permission rules that should always prompt for confirmation', ), defaultMode: z .enum( feature('TRANSCRIPT_CLASSIFIER') ? PERMISSION_MODES : EXTERNAL_PERMISSION_MODES, ) .optional() .describe('Default permission mode when Claude Code needs access'), disableBypassPermissionsMode: z .enum(['disable']) .optional() .describe('Disable the ability to bypass permission prompts'), ...(feature('TRANSCRIPT_CLASSIFIER') ? { disableAutoMode: z .enum(['disable']) .optional() .describe('Disable auto mode'), } : {}), additionalDirectories: z .array(z.string()) .optional() .describe('Additional directories to include in the permission scope'), }) .passthrough(), ) /** * Schema for extra marketplaces defined in repository settings * Same as KnownMarketplace but without lastUpdated (which is managed automatically) */ export const ExtraKnownMarketplaceSchema = lazySchema(() => z.object({ source: MarketplaceSourceSchema().describe( 'Where to fetch the marketplace from', ), installLocation: z .string() .optional() .describe( 'Local cache path where marketplace manifest is stored (auto-generated if not provided)', ), autoUpdate: z .boolean() .optional() .describe( 'Whether to automatically update this marketplace and its installed plugins on startup', ), }), ) /** * Schema for allowed MCP server entry in enterprise allowlist. * Supports matching by serverName, serverCommand, or serverUrl (mutually exclusive). */ export const AllowedMcpServerEntrySchema = lazySchema(() => z .object({ serverName: z .string() .regex( /^[a-zA-Z0-9_-]+$/, 'Server name can only contain letters, numbers, hyphens, and underscores', ) .optional() .describe('Name of the MCP server that users are allowed to configure'), serverCommand: z .array(z.string()) .min(1, 'Server command must have at least one element (the command)') .optional() .describe( 'Command array [command, ...args] to match exactly for allowed stdio servers', ), serverUrl: z .string() .optional() .describe( 'URL pattern with wildcard support (e.g., "https://*.example.com/*") for allowed remote MCP servers', ), // Future extensibility: allowedTransports, requiredArgs, maxInstances, etc. }) .refine( data => { const defined = count( [ data.serverName !== undefined, data.serverCommand !== undefined, data.serverUrl !== undefined, ], Boolean, ) return defined === 1 }, { message: 'Entry must have exactly one of "serverName", "serverCommand", or "serverUrl"', }, ), ) /** * Schema for denied MCP server entry in enterprise denylist. * Supports matching by serverName, serverCommand, or serverUrl (mutually exclusive). */ export const DeniedMcpServerEntrySchema = lazySchema(() => z .object({ serverName: z .string() .regex( /^[a-zA-Z0-9_-]+$/, 'Server name can only contain letters, numbers, hyphens, and underscores', ) .optional() .describe('Name of the MCP server that is explicitly blocked'), serverCommand: z .array(z.string()) .min(1, 'Server command must have at least one element (the command)') .optional() .describe( 'Command array [command, ...args] to match exactly for blocked stdio servers', ), serverUrl: z .string() .optional() .describe( 'URL pattern with wildcard support (e.g., "https://*.example.com/*") for blocked remote MCP servers', ), // Future extensibility: reason, blockedSince, etc. }) .refine( data => { const defined = count( [ data.serverName !== undefined, data.serverCommand !== undefined, data.serverUrl !== undefined, ], Boolean, ) return defined === 1 }, { message: 'Entry must have exactly one of "serverName", "serverCommand", or "serverUrl"', }, ), ) /** * Unified schema for settings files * * ⚠️ BACKWARD COMPATIBILITY NOTICE ⚠️ * * This schema defines the structure of user settings files (.claude/settings.json). * We support backward-compatible changes! Here's how: * * ✅ ALLOWED CHANGES: * - Adding new optional fields (always use .optional()) * - Adding new enum values (keeping existing ones) * - Adding new properties to objects * - Making validation more permissive * - Using union types for gradual migration (e.g., z.union([oldType, newType])) * * ❌ BREAKING CHANGES TO AVOID: * - Removing fields (mark as deprecated instead) * - Removing enum values * - Making optional fields required * - Making types more restrictive * - Renaming fields without keeping the old name * * TO ENSURE BACKWARD COMPATIBILITY: * 1. Run: npm run test:file -- test/utils/settings/backward-compatibility.test.ts * 2. If tests fail, you've introduced a breaking change * 3. When adding new fields, add a test to BACKWARD_COMPATIBILITY_CONFIGS * * The settings system handles backward compatibility automatically: * - When updating settings, invalid fields are preserved in the file (see settings.ts lines 233-249) * - Type coercion via z.coerce (e.g., env vars convert numbers to strings) * - .passthrough() preserves unknown fields in permissions object * - Invalid settings are simply not used, but remain in the file to be fixed by the user */ /** * Surfaces lockable by `strictPluginOnlyCustomization`. Exported so the * schema preprocess (below) and the runtime helper (pluginOnlyPolicy.ts) * share one source of truth. */ export const CUSTOMIZATION_SURFACES = [ 'skills', 'agents', 'hooks', 'mcp', ] as const export const SettingsSchema = lazySchema(() => z .object({ $schema: z .literal(CLAUDE_CODE_SETTINGS_SCHEMA_URL) .optional() .describe('JSON Schema reference for Claude Code settings'), apiKeyHelper: z .string() .optional() .describe('Path to a script that outputs authentication values'), awsCredentialExport: z .string() .optional() .describe('Path to a script that exports AWS credentials'), awsAuthRefresh: z .string() .optional() .describe('Path to a script that refreshes AWS authentication'), gcpAuthRefresh: z .string() .optional() .describe( 'Command to refresh GCP authentication (e.g., gcloud auth application-default login)', ), // Gated so the SDK generator (which runs without CLAUDE_CODE_ENABLE_XAA) // doesn't surface this in GlobalClaudeSettings. Read via getXaaIdpSettings(). // .passthrough() on the outer object keeps an existing settings.json key // alive across env-var-off sessions — it's just not schema-validated then. ...(isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_XAA) ? { xaaIdp: z .object({ issuer: z .string() .url() .describe('IdP issuer URL for OIDC discovery'), clientId: z .string() .describe("Claude Code's client_id registered at the IdP"), callbackPort: z .number() .int() .positive() .optional() .describe( 'Fixed loopback callback port for the IdP OIDC login. ' + 'Only needed if the IdP does not honor RFC 8252 port-any matching.', ), }) .optional() .describe( 'XAA (SEP-990) IdP connection. Configure once; all XAA-enabled MCP servers reuse this.', ), } : {}), fileSuggestion: z .object({ type: z.literal('command'), command: z.string(), }) .optional() .describe('Custom file suggestion configuration for @ mentions'), respectGitignore: z .boolean() .optional() .describe( 'Whether file picker should respect .gitignore files (default: true). ' + 'Note: .ignore files are always respected.', ), cleanupPeriodDays: z .number() .nonnegative() .int() .optional() .describe( 'Number of days to retain chat transcripts (default: 30). Setting to 0 disables session persistence entirely: no transcripts are written and existing transcripts are deleted at startup.', ), env: EnvironmentVariablesSchema() .optional() .describe('Environment variables to set for Claude Code sessions'), // Attribution for commits and PRs attribution: z .object({ commit: z .string() .optional() .describe( 'Attribution text for git commits, including any trailers. ' + 'Empty string hides attribution.', ), pr: z .string() .optional() .describe( 'Attribution text for pull request descriptions. ' + 'Empty string hides attribution.', ), }) .optional() .describe( 'Customize attribution text for commits and PRs. ' + 'Each field defaults to the standard Claude Code attribution if not set.', ), includeCoAuthoredBy: z .boolean() .optional() .describe( 'Deprecated: Use attribution instead. ' + "Whether to include Claude's co-authored by attribution in commits and PRs (defaults to true)", ), includeGitInstructions: z .boolean() .optional() .describe( "Include built-in commit and PR workflow instructions in Claude's system prompt (default: true)", ), permissions: PermissionsSchema() .optional() .describe('Tool usage permissions configuration'), model: z .string() .optional() .describe('Override the default model used by Claude Code'), // Enterprise allowlist of models availableModels: z .array(z.string()) .optional() .describe( 'Allowlist of models that users can select. ' + 'Accepts family aliases ("opus" allows any opus version), ' + 'version prefixes ("opus-4-5" allows only that version), ' + 'and full model IDs. ' + 'If undefined, all models are available. If empty array, only the default model is available. ' + 'Typically set in managed settings by enterprise administrators.', ), modelOverrides: z .record(z.string(), z.string()) .optional() .describe( 'Override mapping from Anthropic model ID (e.g. "claude-opus-4-6") to provider-specific ' + 'model ID (e.g. a Bedrock inference profile ARN). Typically set in managed settings by ' + 'enterprise administrators.', ), // Whether to automatically approve all MCP servers in the project enableAllProjectMcpServers: z .boolean() .optional() .describe( 'Whether to automatically approve all MCP servers in the project', ), // List of approved MCP servers from .mcp.json enabledMcpjsonServers: z .array(z.string()) .optional() .describe('List of approved MCP servers from .mcp.json'), // List of rejected MCP servers from .mcp.json disabledMcpjsonServers: z .array(z.string()) .optional() .describe('List of rejected MCP servers from .mcp.json'), // Enterprise allowlist of MCP servers allowedMcpServers: z .array(AllowedMcpServerEntrySchema()) .optional() .describe( 'Enterprise allowlist of MCP servers that can be used. ' + 'Applies to all scopes including enterprise servers from managed-mcp.json. ' + 'If undefined, all servers are allowed. If empty array, no servers are allowed. ' + 'Denylist takes precedence - if a server is on both lists, it is denied.', ), // Enterprise denylist of MCP servers deniedMcpServers: z .array(DeniedMcpServerEntrySchema()) .optional() .describe( 'Enterprise denylist of MCP servers that are explicitly blocked. ' + 'If a server is on the denylist, it will be blocked across all scopes including enterprise. ' + 'Denylist takes precedence over allowlist - if a server is on both lists, it is denied.', ), hooks: HooksSchema() .optional() .describe('Custom commands to run before/after tool executions'), worktree: z .object({ symlinkDirectories: z .array(z.string()) .optional() .describe( 'Directories to symlink from main repository to worktrees to avoid disk bloat. ' + 'Must be explicitly configured - no directories are symlinked by default. ' + 'Common examples: "node_modules", ".cache", ".bin"', ), sparsePaths: z .array(z.string()) .optional() .describe( 'Directories to include when creating worktrees, via git sparse-checkout (cone mode). ' + 'Dramatically faster in large monorepos — only the listed paths are written to disk.', ), }) .optional() .describe('Git worktree configuration for --worktree flag.'), // Whether to disable all hooks and statusLine disableAllHooks: z .boolean() .optional() .describe('Disable all hooks and statusLine execution'), // Which shell backs input-box `!` (see docs/design/ps-shell-selection.md §4.2) defaultShell: z .enum(['bash', 'powershell']) .optional() .describe( 'Default shell for input-box ! commands. ' + "Defaults to 'bash' on all platforms (no Windows auto-flip).", ), // Only run hooks defined in managed settings (managed-settings.json) allowManagedHooksOnly: z .boolean() .optional() .describe( 'When true (and set in managed settings), only hooks from managed settings run. ' + 'User, project, and local hooks are ignored.', ), // Allowlist of URL patterns HTTP hooks may target (follows allowedMcpServers precedent) allowedHttpHookUrls: z .array(z.string()) .optional() .describe( 'Allowlist of URL patterns that HTTP hooks may target. ' + 'Supports * as a wildcard (e.g. "https://hooks.example.com/*"). ' + 'When set, HTTP hooks with non-matching URLs are blocked. ' + 'If undefined, all URLs are allowed. If empty array, no HTTP hooks are allowed. ' + 'Arrays merge across settings sources (same semantics as allowedMcpServers).', ), // Allowlist of env var names HTTP hooks may interpolate into headers httpHookAllowedEnvVars: z .array(z.string()) .optional() .describe( 'Allowlist of environment variable names HTTP hooks may interpolate into headers. ' + "When set, each hook's effective allowedEnvVars is the intersection with this list. " + 'If undefined, no restriction is applied. ' + 'Arrays merge across settings sources (same semantics as allowedMcpServers).', ), // Only use permission rules defined in managed settings (managed-settings.json) allowManagedPermissionRulesOnly: z .boolean() .optional() .describe( 'When true (and set in managed settings), only permission rules (allow/deny/ask) from managed settings are respected. ' + 'User, project, local, and CLI argument permission rules are ignored.', ), // Only read MCP allowlist policy from managed settings allowManagedMcpServersOnly: z .boolean() .optional() .describe( 'When true (and set in managed settings), allowedMcpServers is only read from managed settings. ' + 'deniedMcpServers still merges from all sources, so users can deny servers for themselves. ' + 'Users can still add their own MCP servers, but only the admin-defined allowlist applies.', ), // Force customizations through plugins only (LinkedIn ask via GTM) strictPluginOnlyCustomization: z .preprocess( // Forwards-compat: drop unknown surface names so a future enum // value (e.g. 'commands') doesn't fail safeParse and null out the // ENTIRE managed-settings file (settings.ts:101). ["skills", // "commands"] on an old client → ["skills"] → locks what it knows, // ignores what it doesn't. Degrades to less-locked, never to // everything-unlocked. v => Array.isArray(v) ? v.filter(x => (CUSTOMIZATION_SURFACES as readonly string[]).includes(x), ) : v, z.union([z.boolean(), z.array(z.enum(CUSTOMIZATION_SURFACES))]), ) .optional() // Non-array invalid values ("skills" string, {object}) pass through // the preprocess unchanged and would fail the union → null the whole // managed-settings file. .catch drops the field to undefined instead. // Degrades to unlocked-for-this-field, never to everything-broken. // Doctor flags the raw value. .catch(undefined) .describe( 'When set in managed settings, blocks non-plugin customization sources for the listed surfaces. ' + 'Array form locks specific surfaces (e.g. ["skills", "hooks"]); `true` locks all four; `false` is an explicit no-op. ' + 'Blocked: ~/.claude/{surface}/, .claude/{surface}/ (project), settings.json hooks, .mcp.json. ' + 'NOT blocked: managed (policySettings) sources, plugin-provided customizations. ' + 'Composes with strictKnownMarketplaces for end-to-end admin control — plugins gated by ' + 'marketplace allowlist, everything else blocked here.', ), // Status line for custom status line display statusLine: z .object({ type: z.literal('command'), command: z.string(), padding: z.number().optional(), }) .optional() .describe('Custom status line display configuration'), // Enabled plugins using marketplace-first format enabledPlugins: z .record( z.string(), z.union([z.array(z.string()), z.boolean(), z.undefined()]), ) .optional() .describe( 'Enabled plugins using plugin-id@marketplace-id format. Example: { "formatter@anthropic-tools": true }. Also supports extended format with version constraints.', ), // Extra marketplaces for this repository (usually for project settings) extraKnownMarketplaces: z .record(z.string(), ExtraKnownMarketplaceSchema()) .check(ctx => { // For settings sources, key must equal source.name. diffMarketplaces // looks up materialized state by dict key; addMarketplaceSource stores // under marketplace.name (= source.name for settings). A mismatch means // the reconciler never converges — every session: key-lookup misses → // 'missing' → source-idempotency returns alreadyMaterialized but // installed++ anyway → pointless cache clears. For github/git/url the // name comes from a fetched marketplace.json (mismatch is expected and // benign); for settings, both key and name are user-authored in the // same JSON object. for (const [key, entry] of Object.entries(ctx.value)) { if ( entry.source.source === 'settings' && entry.source.name !== key ) { ctx.issues.push({ code: 'custom', input: entry.source.name, path: [key, 'source', 'name'], message: `Settings-sourced marketplace name must match its extraKnownMarketplaces key ` + `(got key "${key}" but source.name "${entry.source.name}")`, }) } } }) .optional() .describe( 'Additional marketplaces to make available for this repository. Typically used in repository .claude/settings.json to ensure team members have required plugin sources.', ), // Enterprise strict list of allowed marketplace sources (policy settings only) // When set, ONLY these exact sources can be added. Check happens BEFORE download. strictKnownMarketplaces: z .array(MarketplaceSourceSchema()) .optional() .describe( 'Enterprise strict list of allowed marketplace sources. When set in managed settings, ' + 'ONLY these exact sources can be added as marketplaces. The check happens BEFORE ' + 'downloading, so blocked sources never touch the filesystem. ' + 'Note: this is a policy gate only — it does NOT register marketplaces. ' + 'To pre-register allowed marketplaces for users, also set extraKnownMarketplaces.', ), // Enterprise blocklist of marketplace sources (policy settings only) // When set, these exact sources are blocked. Check happens BEFORE download. blockedMarketplaces: z .array(MarketplaceSourceSchema()) .optional() .describe( 'Enterprise blocklist of marketplace sources. When set in managed settings, ' + 'these exact sources are blocked from being added as marketplaces. The check happens BEFORE ' + 'downloading, so blocked sources never touch the filesystem.', ), // Force a specific login method: 'claudeai' for Claude Pro/Max, 'console' for Console billing forceLoginMethod: z .enum(['claudeai', 'console']) .optional() .describe( 'Force a specific login method: "claudeai" for Claude Pro/Max, "console" for Console billing', ), // Organization UUID to use for OAuth login (will be added as URL param to authorization URL) forceLoginOrgUUID: z .string() .optional() .describe('Organization UUID to use for OAuth login'), otelHeadersHelper: z .string() .optional() .describe('Path to a script that outputs OpenTelemetry headers'), outputStyle: z .string() .optional() .describe('Controls the output style for assistant responses'), language: z .string() .optional() .describe( 'Preferred language for Claude responses and voice dictation (e.g., "japanese", "spanish")', ), skipWebFetchPreflight: z .boolean() .optional() .describe( 'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies', ), sandbox: SandboxSettingsSchema().optional(), feedbackSurveyRate: z .number() .min(0) .max(1) .optional() .describe( 'Probability (0–1) that the session quality survey appears when eligible. 0.05 is a reasonable starting point.', ), spinnerTipsEnabled: z .boolean() .optional() .describe('Whether to show tips in the spinner'), spinnerVerbs: z .object({ mode: z.enum(['append', 'replace']), verbs: z.array(z.string()), }) .optional() .describe( 'Customize spinner verbs. mode: "append" adds verbs to defaults, "replace" uses only your verbs.', ), spinnerTipsOverride: z .object({ excludeDefault: z.boolean().optional(), tips: z.array(z.string()), }) .optional() .describe( 'Override spinner tips. tips: array of tip strings. excludeDefault: if true, only show custom tips (default: false).', ), syntaxHighlightingDisabled: z .boolean() .optional() .describe('Whether to disable syntax highlighting in diffs'), terminalTitleFromRename: z .boolean() .optional() .describe( 'Whether /rename updates the terminal tab title (defaults to true). Set to false to keep auto-generated topic titles.', ), alwaysThinkingEnabled: z .boolean() .optional() .describe( 'When false, thinking is disabled. When absent or true, thinking is ' + 'enabled automatically for supported models.', ), effortLevel: z .enum( process.env.USER_TYPE === 'ant' ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high'], ) .optional() .catch(undefined) .describe('Persisted effort level for supported models.'), advisorModel: z .string() .optional() .describe('Advisor model for the server-side advisor tool.'), fastMode: z .boolean() .optional() .describe( 'When true, fast mode is enabled. When absent or false, fast mode is off.', ), fastModePerSessionOptIn: z .boolean() .optional() .describe( 'When true, fast mode does not persist across sessions. Each session starts with fast mode off.', ), promptSuggestionEnabled: z .boolean() .optional() .describe( 'When false, prompt suggestions are disabled. When absent or true, ' + 'prompt suggestions are enabled.', ), showClearContextOnPlanAccept: z .boolean() .optional() .describe( 'When true, the plan-approval dialog offers a "clear context" option. Defaults to false.', ), agent: z .string() .optional() .describe( 'Name of an agent (built-in or custom) to use for the main thread. ' + "Applies the agent's system prompt, tool restrictions, and model.", ), companyAnnouncements: z .array(z.string()) .optional() .describe( 'Company announcements to display at startup (one will be randomly selected if multiple are provided)', ), pluginConfigs: z .record( z.string(), z.object({ mcpServers: z .record( z.string(), z.record( z.string(), z.union([ z.string(), z.number(), z.boolean(), z.array(z.string()), ]), ), ) .optional() .describe( 'User configuration values for MCP servers keyed by server name', ), options: z .record( z.string(), z.union([ z.string(), z.number(), z.boolean(), z.array(z.string()), ]), ) .optional() .describe( 'Non-sensitive option values from plugin manifest userConfig, keyed by option name. Sensitive values go to secure storage instead.', ), }), ) .optional() .describe( 'Per-plugin configuration including MCP server user configs, keyed by plugin ID (plugin@marketplace format)', ), remote: z .object({ defaultEnvironmentId: z .string() .optional() .describe('Default environment ID to use for remote sessions'), }) .optional() .describe('Remote session configuration'), autoUpdatesChannel: z .enum(['latest', 'stable']) .optional() .describe('Release channel for auto-updates (latest or stable)'), ...(feature('LODESTONE') ? { disableDeepLinkRegistration: z .enum(['disable']) .optional() .describe( 'Prevent claude-cli:// protocol handler registration with the OS', ), } : {}), minimumVersion: z .string() .optional() .describe( 'Minimum version to stay on - prevents downgrades when switching to stable channel', ), plansDirectory: z .string() .optional() .describe( 'Custom directory for plan files, relative to project root. ' + 'If not set, defaults to ~/.claude/plans/', ), ...(process.env.USER_TYPE === 'ant' ? { classifierPermissionsEnabled: z .boolean() .optional() .describe( 'Enable AI-based classification for Bash(prompt:...) permission rules', ), } : {}), ...(feature('PROACTIVE') || feature('KAIROS') ? { minSleepDurationMs: z .number() .nonnegative() .int() .optional() .describe( 'Minimum duration in milliseconds that the Sleep tool must sleep for. ' + 'Useful for throttling proactive tick frequency.', ), maxSleepDurationMs: z .number() .int() .min(-1) .optional() .describe( 'Maximum duration in milliseconds that the Sleep tool can sleep for. ' + 'Set to -1 for indefinite sleep (waits for user input). ' + 'Useful for limiting idle time in remote/managed environments.', ), } : {}), ...(feature('VOICE_MODE') ? { voiceEnabled: z .boolean() .optional() .describe('Enable voice mode (hold-to-talk dictation)'), } : {}), ...(feature('KAIROS') ? { assistant: z .boolean() .optional() .describe( 'Start Claude in assistant mode (custom system prompt, brief view, scheduled check-in skills)', ), assistantName: z .string() .optional() .describe( 'Display name for the assistant, shown in the claude.ai session list', ), } : {}), // Teams/Enterprise opt-IN for channel notifications. Default OFF. // MCP servers that declare the claude/channel capability can push // inbound messages into the conversation; for managed orgs this only // works when explicitly enabled. Which servers can connect at all is // still governed by allowedMcpServers/deniedMcpServers. Not // feature-spread: KAIROS_CHANNELS is external:true, and the spread // wrecks type inference for allowedChannelPlugins (the .passthrough() // catch-all gives {} instead of the array type). channelsEnabled: z .boolean() .optional() .describe( 'Teams/Enterprise opt-in for channel notifications (MCP servers with the ' + 'claude/channel capability pushing inbound messages). Default off. ' + 'Set true to allow; users then select servers via --channels.', ), // Org-level channel plugin allowlist. When set, REPLACES the // Anthropic ledger — admin owns the trust decision. Undefined means // fall back to the ledger. Plugin-only entry shape (same as the // ledger); server-kind entries still need the dev flag. allowedChannelPlugins: z .array( z.object({ marketplace: z.string(), plugin: z.string(), }), ) .optional() .describe( 'Teams/Enterprise allowlist of channel plugins. When set, ' + 'replaces the default Anthropic allowlist — admins decide which ' + 'plugins may push inbound messages. Undefined falls back to the default. ' + 'Requires channelsEnabled: true.', ), ...(feature('KAIROS') || feature('KAIROS_BRIEF') ? { defaultView: z .enum(['chat', 'transcript']) .optional() .describe( 'Default transcript view: chat (SendUserMessage checkpoints only) or transcript (full)', ), } : {}), prefersReducedMotion: z .boolean() .optional() .describe( 'Reduce or disable animations for accessibility (spinner shimmer, flash effects, etc.)', ), autoMemoryEnabled: z .boolean() .optional() .describe( 'Enable auto-memory for this project. When false, Claude will not read from or write to the auto-memory directory.', ), autoMemoryDirectory: z .string() .optional() .describe( 'Custom directory path for auto-memory storage. Supports ~/ prefix for home directory expansion. Ignored if set in projectSettings (checked-in .claude/settings.json) for security. When unset, defaults to ~/.claude/projects//memory/.', ), autoDreamEnabled: z .boolean() .optional() .describe( 'Enable background memory consolidation (auto-dream). When set, overrides the server-side default.', ), showThinkingSummaries: z .boolean() .optional() .describe( 'Show thinking summaries in the transcript view (ctrl+o). Default: false.', ), skipDangerousModePermissionPrompt: z .boolean() .optional() .describe( 'Whether the user has accepted the bypass permissions mode dialog', ), ...(feature('TRANSCRIPT_CLASSIFIER') ? { skipAutoPermissionPrompt: z .boolean() .optional() .describe( 'Whether the user has accepted the auto mode opt-in dialog', ), useAutoModeDuringPlan: z .boolean() .optional() .describe( 'Whether plan mode uses auto mode semantics when auto mode is available (default: true)', ), autoMode: z .object({ allow: z .array(z.string()) .optional() .describe('Rules for the auto mode classifier allow section'), soft_deny: z .array(z.string()) .optional() .describe('Rules for the auto mode classifier deny section'), ...(process.env.USER_TYPE === 'ant' ? { // Back-compat alias for ant users; external users use soft_deny deny: z.array(z.string()).optional(), } : {}), environment: z .array(z.string()) .optional() .describe( 'Entries for the auto mode classifier environment section', ), }) .optional() .describe('Auto mode classifier prompt customization'), } : {}), disableAutoMode: z .enum(['disable']) .optional() .describe('Disable auto mode'), sshConfigs: z .array( z.object({ id: z .string() .describe( 'Unique identifier for this SSH config. Used to match configs across settings sources.', ), name: z.string().describe('Display name for the SSH connection'), sshHost: z .string() .describe( 'SSH host in format "user@hostname" or "hostname", or a host alias from ~/.ssh/config', ), sshPort: z .number() .int() .optional() .describe('SSH port (default: 22)'), sshIdentityFile: z .string() .optional() .describe('Path to SSH identity file (private key)'), startDirectory: z .string() .optional() .describe( 'Default working directory on the remote host. ' + 'Supports tilde expansion (e.g. ~/projects). ' + 'If not specified, defaults to the remote user home directory. ' + 'Can be overridden by the [dir] positional argument in `claude ssh [dir]`.', ), }), ) .optional() .describe( 'SSH connection configurations for remote environments. ' + 'Typically set in managed settings by enterprise administrators ' + 'to pre-configure SSH connections for team members.', ), claudeMdExcludes: z .array(z.string()) .optional() .describe( 'Glob patterns or absolute paths of CLAUDE.md files to exclude from loading. ' + 'Patterns are matched against absolute file paths using picomatch. ' + 'Only applies to User, Project, and Local memory types (Managed/policy files cannot be excluded). ' + 'Examples: "/home/user/monorepo/CLAUDE.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"', ), pluginTrustMessage: z .string() .optional() .describe( 'Custom message to append to the plugin trust warning shown before installation. ' + 'Only read from policy settings (managed-settings.json / MDM). ' + 'Useful for enterprise administrators to add organization-specific context ' + '(e.g., "All plugins from our internal marketplace are vetted and approved.").', ), }) .passthrough(), ) /** * Internal type for plugin hooks - includes plugin context for execution. * Not a Zod schema since it's not user-facing (plugins provide native hooks). */ export type PluginHookMatcher = { matcher?: string hooks: HookCommand[] pluginRoot: string pluginName: string pluginId: string // format: "pluginName@marketplaceName" } /** * Internal type for skill hooks - includes skill context for execution. * Not a Zod schema since it's not user-facing (skills provide native hooks). */ export type SkillHookMatcher = { matcher?: string hooks: HookCommand[] skillRoot: string skillName: string } export type AllowedMcpServerEntry = z.infer< ReturnType > export type DeniedMcpServerEntry = z.infer< ReturnType > export type SettingsJson = z.infer> /** * Type guard for MCP server entry with serverName */ export function isMcpServerNameEntry( entry: AllowedMcpServerEntry | DeniedMcpServerEntry, ): entry is { serverName: string } { return 'serverName' in entry && entry.serverName !== undefined } /** * Type guard for MCP server entry with serverCommand */ export function isMcpServerCommandEntry( entry: AllowedMcpServerEntry | DeniedMcpServerEntry, ): entry is { serverCommand: string[] } { return 'serverCommand' in entry && entry.serverCommand !== undefined } /** * Type guard for MCP server entry with serverUrl */ export function isMcpServerUrlEntry( entry: AllowedMcpServerEntry | DeniedMcpServerEntry, ): entry is { serverUrl: string } { return 'serverUrl' in entry && entry.serverUrl !== undefined } /** * User configuration values for MCPB MCP servers */ export type UserConfigValues = Record< string, string | number | boolean | string[] > /** * Plugin configuration stored in settings.json */ export type PluginConfig = { mcpServers?: { [serverName: string]: UserConfigValues } }