/ utils / settings / types.ts
types.ts
   1  import { feature } from 'bun:bundle'
   2  import { z } from 'zod/v4'
   3  import { SandboxSettingsSchema } from '../../entrypoints/sandboxTypes.js'
   4  import { isEnvTruthy } from '../envUtils.js'
   5  import { lazySchema } from '../lazySchema.js'
   6  import {
   7    EXTERNAL_PERMISSION_MODES,
   8    PERMISSION_MODES,
   9  } from '../permissions/PermissionMode.js'
  10  import { MarketplaceSourceSchema } from '../plugins/schemas.js'
  11  import { CLAUDE_CODE_SETTINGS_SCHEMA_URL } from './constants.js'
  12  import { PermissionRuleSchema } from './permissionValidation.js'
  13  
  14  // Re-export hook schemas and types from centralized location for backward compatibility
  15  export {
  16    type AgentHook,
  17    type BashCommandHook,
  18    type HookCommand,
  19    HookCommandSchema,
  20    type HookMatcher,
  21    HookMatcherSchema,
  22    HooksSchema,
  23    type HooksSettings,
  24    type HttpHook,
  25    type PromptHook,
  26  } from '../../schemas/hooks.js'
  27  
  28  // Also import for use within this file
  29  import { type HookCommand, HooksSchema } from '../../schemas/hooks.js'
  30  import { count } from '../array.js'
  31  
  32  /**
  33   * Schema for environment variables
  34   */
  35  export const EnvironmentVariablesSchema = lazySchema(() =>
  36    z.record(z.string(), z.coerce.string()),
  37  )
  38  
  39  /**
  40   * Schema for permissions section
  41   */
  42  export const PermissionsSchema = lazySchema(() =>
  43    z
  44      .object({
  45        allow: z
  46          .array(PermissionRuleSchema())
  47          .optional()
  48          .describe('List of permission rules for allowed operations'),
  49        deny: z
  50          .array(PermissionRuleSchema())
  51          .optional()
  52          .describe('List of permission rules for denied operations'),
  53        ask: z
  54          .array(PermissionRuleSchema())
  55          .optional()
  56          .describe(
  57            'List of permission rules that should always prompt for confirmation',
  58          ),
  59        defaultMode: z
  60          .enum(
  61            feature('TRANSCRIPT_CLASSIFIER')
  62              ? PERMISSION_MODES
  63              : EXTERNAL_PERMISSION_MODES,
  64          )
  65          .optional()
  66          .describe('Default permission mode when Claude Code needs access'),
  67        disableBypassPermissionsMode: z
  68          .enum(['disable'])
  69          .optional()
  70          .describe('Disable the ability to bypass permission prompts'),
  71        ...(feature('TRANSCRIPT_CLASSIFIER')
  72          ? {
  73              disableAutoMode: z
  74                .enum(['disable'])
  75                .optional()
  76                .describe('Disable auto mode'),
  77            }
  78          : {}),
  79        additionalDirectories: z
  80          .array(z.string())
  81          .optional()
  82          .describe('Additional directories to include in the permission scope'),
  83      })
  84      .passthrough(),
  85  )
  86  
  87  /**
  88   * Schema for extra marketplaces defined in repository settings
  89   * Same as KnownMarketplace but without lastUpdated (which is managed automatically)
  90   */
  91  export const ExtraKnownMarketplaceSchema = lazySchema(() =>
  92    z.object({
  93      source: MarketplaceSourceSchema().describe(
  94        'Where to fetch the marketplace from',
  95      ),
  96      installLocation: z
  97        .string()
  98        .optional()
  99        .describe(
 100          'Local cache path where marketplace manifest is stored (auto-generated if not provided)',
 101        ),
 102      autoUpdate: z
 103        .boolean()
 104        .optional()
 105        .describe(
 106          'Whether to automatically update this marketplace and its installed plugins on startup',
 107        ),
 108    }),
 109  )
 110  
 111  /**
 112   * Schema for allowed MCP server entry in enterprise allowlist.
 113   * Supports matching by serverName, serverCommand, or serverUrl (mutually exclusive).
 114   */
 115  export const AllowedMcpServerEntrySchema = lazySchema(() =>
 116    z
 117      .object({
 118        serverName: z
 119          .string()
 120          .regex(
 121            /^[a-zA-Z0-9_-]+$/,
 122            'Server name can only contain letters, numbers, hyphens, and underscores',
 123          )
 124          .optional()
 125          .describe('Name of the MCP server that users are allowed to configure'),
 126        serverCommand: z
 127          .array(z.string())
 128          .min(1, 'Server command must have at least one element (the command)')
 129          .optional()
 130          .describe(
 131            'Command array [command, ...args] to match exactly for allowed stdio servers',
 132          ),
 133        serverUrl: z
 134          .string()
 135          .optional()
 136          .describe(
 137            'URL pattern with wildcard support (e.g., "https://*.example.com/*") for allowed remote MCP servers',
 138          ),
 139        // Future extensibility: allowedTransports, requiredArgs, maxInstances, etc.
 140      })
 141      .refine(
 142        data => {
 143          const defined = count(
 144            [
 145              data.serverName !== undefined,
 146              data.serverCommand !== undefined,
 147              data.serverUrl !== undefined,
 148            ],
 149            Boolean,
 150          )
 151          return defined === 1
 152        },
 153        {
 154          message:
 155            'Entry must have exactly one of "serverName", "serverCommand", or "serverUrl"',
 156        },
 157      ),
 158  )
 159  
 160  /**
 161   * Schema for denied MCP server entry in enterprise denylist.
 162   * Supports matching by serverName, serverCommand, or serverUrl (mutually exclusive).
 163   */
 164  export const DeniedMcpServerEntrySchema = lazySchema(() =>
 165    z
 166      .object({
 167        serverName: z
 168          .string()
 169          .regex(
 170            /^[a-zA-Z0-9_-]+$/,
 171            'Server name can only contain letters, numbers, hyphens, and underscores',
 172          )
 173          .optional()
 174          .describe('Name of the MCP server that is explicitly blocked'),
 175        serverCommand: z
 176          .array(z.string())
 177          .min(1, 'Server command must have at least one element (the command)')
 178          .optional()
 179          .describe(
 180            'Command array [command, ...args] to match exactly for blocked stdio servers',
 181          ),
 182        serverUrl: z
 183          .string()
 184          .optional()
 185          .describe(
 186            'URL pattern with wildcard support (e.g., "https://*.example.com/*") for blocked remote MCP servers',
 187          ),
 188        // Future extensibility: reason, blockedSince, etc.
 189      })
 190      .refine(
 191        data => {
 192          const defined = count(
 193            [
 194              data.serverName !== undefined,
 195              data.serverCommand !== undefined,
 196              data.serverUrl !== undefined,
 197            ],
 198            Boolean,
 199          )
 200          return defined === 1
 201        },
 202        {
 203          message:
 204            'Entry must have exactly one of "serverName", "serverCommand", or "serverUrl"',
 205        },
 206      ),
 207  )
 208  
 209  /**
 210   * Unified schema for settings files
 211   *
 212   * ⚠️ BACKWARD COMPATIBILITY NOTICE ⚠️
 213   *
 214   * This schema defines the structure of user settings files (.claude/settings.json).
 215   * We support backward-compatible changes! Here's how:
 216   *
 217   * ✅ ALLOWED CHANGES:
 218   * - Adding new optional fields (always use .optional())
 219   * - Adding new enum values (keeping existing ones)
 220   * - Adding new properties to objects
 221   * - Making validation more permissive
 222   * - Using union types for gradual migration (e.g., z.union([oldType, newType]))
 223   *
 224   * ❌ BREAKING CHANGES TO AVOID:
 225   * - Removing fields (mark as deprecated instead)
 226   * - Removing enum values
 227   * - Making optional fields required
 228   * - Making types more restrictive
 229   * - Renaming fields without keeping the old name
 230   *
 231   * TO ENSURE BACKWARD COMPATIBILITY:
 232   * 1. Run: npm run test:file -- test/utils/settings/backward-compatibility.test.ts
 233   * 2. If tests fail, you've introduced a breaking change
 234   * 3. When adding new fields, add a test to BACKWARD_COMPATIBILITY_CONFIGS
 235   *
 236   * The settings system handles backward compatibility automatically:
 237   * - When updating settings, invalid fields are preserved in the file (see settings.ts lines 233-249)
 238   * - Type coercion via z.coerce (e.g., env vars convert numbers to strings)
 239   * - .passthrough() preserves unknown fields in permissions object
 240   * - Invalid settings are simply not used, but remain in the file to be fixed by the user
 241   */
 242  
 243  /**
 244   * Surfaces lockable by `strictPluginOnlyCustomization`. Exported so the
 245   * schema preprocess (below) and the runtime helper (pluginOnlyPolicy.ts)
 246   * share one source of truth.
 247   */
 248  export const CUSTOMIZATION_SURFACES = [
 249    'skills',
 250    'agents',
 251    'hooks',
 252    'mcp',
 253  ] as const
 254  
 255  export const SettingsSchema = lazySchema(() =>
 256    z
 257      .object({
 258        $schema: z
 259          .literal(CLAUDE_CODE_SETTINGS_SCHEMA_URL)
 260          .optional()
 261          .describe('JSON Schema reference for Claude Code settings'),
 262        apiKeyHelper: z
 263          .string()
 264          .optional()
 265          .describe('Path to a script that outputs authentication values'),
 266        awsCredentialExport: z
 267          .string()
 268          .optional()
 269          .describe('Path to a script that exports AWS credentials'),
 270        awsAuthRefresh: z
 271          .string()
 272          .optional()
 273          .describe('Path to a script that refreshes AWS authentication'),
 274        gcpAuthRefresh: z
 275          .string()
 276          .optional()
 277          .describe(
 278            'Command to refresh GCP authentication (e.g., gcloud auth application-default login)',
 279          ),
 280        // Gated so the SDK generator (which runs without CLAUDE_CODE_ENABLE_XAA)
 281        // doesn't surface this in GlobalClaudeSettings. Read via getXaaIdpSettings().
 282        // .passthrough() on the outer object keeps an existing settings.json key
 283        // alive across env-var-off sessions — it's just not schema-validated then.
 284        ...(isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_XAA)
 285          ? {
 286              xaaIdp: z
 287                .object({
 288                  issuer: z
 289                    .string()
 290                    .url()
 291                    .describe('IdP issuer URL for OIDC discovery'),
 292                  clientId: z
 293                    .string()
 294                    .describe("Claude Code's client_id registered at the IdP"),
 295                  callbackPort: z
 296                    .number()
 297                    .int()
 298                    .positive()
 299                    .optional()
 300                    .describe(
 301                      'Fixed loopback callback port for the IdP OIDC login. ' +
 302                        'Only needed if the IdP does not honor RFC 8252 port-any matching.',
 303                    ),
 304                })
 305                .optional()
 306                .describe(
 307                  'XAA (SEP-990) IdP connection. Configure once; all XAA-enabled MCP servers reuse this.',
 308                ),
 309            }
 310          : {}),
 311        fileSuggestion: z
 312          .object({
 313            type: z.literal('command'),
 314            command: z.string(),
 315          })
 316          .optional()
 317          .describe('Custom file suggestion configuration for @ mentions'),
 318        respectGitignore: z
 319          .boolean()
 320          .optional()
 321          .describe(
 322            'Whether file picker should respect .gitignore files (default: true). ' +
 323              'Note: .ignore files are always respected.',
 324          ),
 325        cleanupPeriodDays: z
 326          .number()
 327          .nonnegative()
 328          .int()
 329          .optional()
 330          .describe(
 331            '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.',
 332          ),
 333        env: EnvironmentVariablesSchema()
 334          .optional()
 335          .describe('Environment variables to set for Claude Code sessions'),
 336        // Attribution for commits and PRs
 337        attribution: z
 338          .object({
 339            commit: z
 340              .string()
 341              .optional()
 342              .describe(
 343                'Attribution text for git commits, including any trailers. ' +
 344                  'Empty string hides attribution.',
 345              ),
 346            pr: z
 347              .string()
 348              .optional()
 349              .describe(
 350                'Attribution text for pull request descriptions. ' +
 351                  'Empty string hides attribution.',
 352              ),
 353          })
 354          .optional()
 355          .describe(
 356            'Customize attribution text for commits and PRs. ' +
 357              'Each field defaults to the standard Claude Code attribution if not set.',
 358          ),
 359        includeCoAuthoredBy: z
 360          .boolean()
 361          .optional()
 362          .describe(
 363            'Deprecated: Use attribution instead. ' +
 364              "Whether to include Claude's co-authored by attribution in commits and PRs (defaults to true)",
 365          ),
 366        includeGitInstructions: z
 367          .boolean()
 368          .optional()
 369          .describe(
 370            "Include built-in commit and PR workflow instructions in Claude's system prompt (default: true)",
 371          ),
 372        permissions: PermissionsSchema()
 373          .optional()
 374          .describe('Tool usage permissions configuration'),
 375        model: z
 376          .string()
 377          .optional()
 378          .describe('Override the default model used by Claude Code'),
 379        // Enterprise allowlist of models
 380        availableModels: z
 381          .array(z.string())
 382          .optional()
 383          .describe(
 384            'Allowlist of models that users can select. ' +
 385              'Accepts family aliases ("opus" allows any opus version), ' +
 386              'version prefixes ("opus-4-5" allows only that version), ' +
 387              'and full model IDs. ' +
 388              'If undefined, all models are available. If empty array, only the default model is available. ' +
 389              'Typically set in managed settings by enterprise administrators.',
 390          ),
 391        modelOverrides: z
 392          .record(z.string(), z.string())
 393          .optional()
 394          .describe(
 395            'Override mapping from Anthropic model ID (e.g. "claude-opus-4-6") to provider-specific ' +
 396              'model ID (e.g. a Bedrock inference profile ARN). Typically set in managed settings by ' +
 397              'enterprise administrators.',
 398          ),
 399        // Whether to automatically approve all MCP servers in the project
 400        enableAllProjectMcpServers: z
 401          .boolean()
 402          .optional()
 403          .describe(
 404            'Whether to automatically approve all MCP servers in the project',
 405          ),
 406        // List of approved MCP servers from .mcp.json
 407        enabledMcpjsonServers: z
 408          .array(z.string())
 409          .optional()
 410          .describe('List of approved MCP servers from .mcp.json'),
 411        // List of rejected MCP servers from .mcp.json
 412        disabledMcpjsonServers: z
 413          .array(z.string())
 414          .optional()
 415          .describe('List of rejected MCP servers from .mcp.json'),
 416        // Enterprise allowlist of MCP servers
 417        allowedMcpServers: z
 418          .array(AllowedMcpServerEntrySchema())
 419          .optional()
 420          .describe(
 421            'Enterprise allowlist of MCP servers that can be used. ' +
 422              'Applies to all scopes including enterprise servers from managed-mcp.json. ' +
 423              'If undefined, all servers are allowed. If empty array, no servers are allowed. ' +
 424              'Denylist takes precedence - if a server is on both lists, it is denied.',
 425          ),
 426        // Enterprise denylist of MCP servers
 427        deniedMcpServers: z
 428          .array(DeniedMcpServerEntrySchema())
 429          .optional()
 430          .describe(
 431            'Enterprise denylist of MCP servers that are explicitly blocked. ' +
 432              'If a server is on the denylist, it will be blocked across all scopes including enterprise. ' +
 433              'Denylist takes precedence over allowlist - if a server is on both lists, it is denied.',
 434          ),
 435        hooks: HooksSchema()
 436          .optional()
 437          .describe('Custom commands to run before/after tool executions'),
 438        worktree: z
 439          .object({
 440            symlinkDirectories: z
 441              .array(z.string())
 442              .optional()
 443              .describe(
 444                'Directories to symlink from main repository to worktrees to avoid disk bloat. ' +
 445                  'Must be explicitly configured - no directories are symlinked by default. ' +
 446                  'Common examples: "node_modules", ".cache", ".bin"',
 447              ),
 448            sparsePaths: z
 449              .array(z.string())
 450              .optional()
 451              .describe(
 452                'Directories to include when creating worktrees, via git sparse-checkout (cone mode). ' +
 453                  'Dramatically faster in large monorepos — only the listed paths are written to disk.',
 454              ),
 455          })
 456          .optional()
 457          .describe('Git worktree configuration for --worktree flag.'),
 458        // Whether to disable all hooks and statusLine
 459        disableAllHooks: z
 460          .boolean()
 461          .optional()
 462          .describe('Disable all hooks and statusLine execution'),
 463        // Which shell backs input-box `!` (see docs/design/ps-shell-selection.md §4.2)
 464        defaultShell: z
 465          .enum(['bash', 'powershell'])
 466          .optional()
 467          .describe(
 468            'Default shell for input-box ! commands. ' +
 469              "Defaults to 'bash' on all platforms (no Windows auto-flip).",
 470          ),
 471        // Only run hooks defined in managed settings (managed-settings.json)
 472        allowManagedHooksOnly: z
 473          .boolean()
 474          .optional()
 475          .describe(
 476            'When true (and set in managed settings), only hooks from managed settings run. ' +
 477              'User, project, and local hooks are ignored.',
 478          ),
 479        // Allowlist of URL patterns HTTP hooks may target (follows allowedMcpServers precedent)
 480        allowedHttpHookUrls: z
 481          .array(z.string())
 482          .optional()
 483          .describe(
 484            'Allowlist of URL patterns that HTTP hooks may target. ' +
 485              'Supports * as a wildcard (e.g. "https://hooks.example.com/*"). ' +
 486              'When set, HTTP hooks with non-matching URLs are blocked. ' +
 487              'If undefined, all URLs are allowed. If empty array, no HTTP hooks are allowed. ' +
 488              'Arrays merge across settings sources (same semantics as allowedMcpServers).',
 489          ),
 490        // Allowlist of env var names HTTP hooks may interpolate into headers
 491        httpHookAllowedEnvVars: z
 492          .array(z.string())
 493          .optional()
 494          .describe(
 495            'Allowlist of environment variable names HTTP hooks may interpolate into headers. ' +
 496              "When set, each hook's effective allowedEnvVars is the intersection with this list. " +
 497              'If undefined, no restriction is applied. ' +
 498              'Arrays merge across settings sources (same semantics as allowedMcpServers).',
 499          ),
 500        // Only use permission rules defined in managed settings (managed-settings.json)
 501        allowManagedPermissionRulesOnly: z
 502          .boolean()
 503          .optional()
 504          .describe(
 505            'When true (and set in managed settings), only permission rules (allow/deny/ask) from managed settings are respected. ' +
 506              'User, project, local, and CLI argument permission rules are ignored.',
 507          ),
 508        // Only read MCP allowlist policy from managed settings
 509        allowManagedMcpServersOnly: z
 510          .boolean()
 511          .optional()
 512          .describe(
 513            'When true (and set in managed settings), allowedMcpServers is only read from managed settings. ' +
 514              'deniedMcpServers still merges from all sources, so users can deny servers for themselves. ' +
 515              'Users can still add their own MCP servers, but only the admin-defined allowlist applies.',
 516          ),
 517        // Force customizations through plugins only (LinkedIn ask via GTM)
 518        strictPluginOnlyCustomization: z
 519          .preprocess(
 520            // Forwards-compat: drop unknown surface names so a future enum
 521            // value (e.g. 'commands') doesn't fail safeParse and null out the
 522            // ENTIRE managed-settings file (settings.ts:101). ["skills",
 523            // "commands"] on an old client → ["skills"] → locks what it knows,
 524            // ignores what it doesn't. Degrades to less-locked, never to
 525            // everything-unlocked.
 526            v =>
 527              Array.isArray(v)
 528                ? v.filter(x =>
 529                    (CUSTOMIZATION_SURFACES as readonly string[]).includes(x),
 530                  )
 531                : v,
 532            z.union([z.boolean(), z.array(z.enum(CUSTOMIZATION_SURFACES))]),
 533          )
 534          .optional()
 535          // Non-array invalid values ("skills" string, {object}) pass through
 536          // the preprocess unchanged and would fail the union → null the whole
 537          // managed-settings file. .catch drops the field to undefined instead.
 538          // Degrades to unlocked-for-this-field, never to everything-broken.
 539          // Doctor flags the raw value.
 540          .catch(undefined)
 541          .describe(
 542            'When set in managed settings, blocks non-plugin customization sources for the listed surfaces. ' +
 543              'Array form locks specific surfaces (e.g. ["skills", "hooks"]); `true` locks all four; `false` is an explicit no-op. ' +
 544              'Blocked: ~/.claude/{surface}/, .claude/{surface}/ (project), settings.json hooks, .mcp.json. ' +
 545              'NOT blocked: managed (policySettings) sources, plugin-provided customizations. ' +
 546              'Composes with strictKnownMarketplaces for end-to-end admin control — plugins gated by ' +
 547              'marketplace allowlist, everything else blocked here.',
 548          ),
 549        // Status line for custom status line display
 550        statusLine: z
 551          .object({
 552            type: z.literal('command'),
 553            command: z.string(),
 554            padding: z.number().optional(),
 555          })
 556          .optional()
 557          .describe('Custom status line display configuration'),
 558        // Enabled plugins using marketplace-first format
 559        enabledPlugins: z
 560          .record(
 561            z.string(),
 562            z.union([z.array(z.string()), z.boolean(), z.undefined()]),
 563          )
 564          .optional()
 565          .describe(
 566            'Enabled plugins using plugin-id@marketplace-id format. Example: { "formatter@anthropic-tools": true }. Also supports extended format with version constraints.',
 567          ),
 568        // Extra marketplaces for this repository (usually for project settings)
 569        extraKnownMarketplaces: z
 570          .record(z.string(), ExtraKnownMarketplaceSchema())
 571          .check(ctx => {
 572            // For settings sources, key must equal source.name. diffMarketplaces
 573            // looks up materialized state by dict key; addMarketplaceSource stores
 574            // under marketplace.name (= source.name for settings). A mismatch means
 575            // the reconciler never converges — every session: key-lookup misses →
 576            // 'missing' → source-idempotency returns alreadyMaterialized but
 577            // installed++ anyway → pointless cache clears. For github/git/url the
 578            // name comes from a fetched marketplace.json (mismatch is expected and
 579            // benign); for settings, both key and name are user-authored in the
 580            // same JSON object.
 581            for (const [key, entry] of Object.entries(ctx.value)) {
 582              if (
 583                entry.source.source === 'settings' &&
 584                entry.source.name !== key
 585              ) {
 586                ctx.issues.push({
 587                  code: 'custom',
 588                  input: entry.source.name,
 589                  path: [key, 'source', 'name'],
 590                  message:
 591                    `Settings-sourced marketplace name must match its extraKnownMarketplaces key ` +
 592                    `(got key "${key}" but source.name "${entry.source.name}")`,
 593                })
 594              }
 595            }
 596          })
 597          .optional()
 598          .describe(
 599            'Additional marketplaces to make available for this repository. Typically used in repository .claude/settings.json to ensure team members have required plugin sources.',
 600          ),
 601        // Enterprise strict list of allowed marketplace sources (policy settings only)
 602        // When set, ONLY these exact sources can be added. Check happens BEFORE download.
 603        strictKnownMarketplaces: z
 604          .array(MarketplaceSourceSchema())
 605          .optional()
 606          .describe(
 607            'Enterprise strict list of allowed marketplace sources. When set in managed settings, ' +
 608              'ONLY these exact sources can be added as marketplaces. The check happens BEFORE ' +
 609              'downloading, so blocked sources never touch the filesystem. ' +
 610              'Note: this is a policy gate only — it does NOT register marketplaces. ' +
 611              'To pre-register allowed marketplaces for users, also set extraKnownMarketplaces.',
 612          ),
 613        // Enterprise blocklist of marketplace sources (policy settings only)
 614        // When set, these exact sources are blocked. Check happens BEFORE download.
 615        blockedMarketplaces: z
 616          .array(MarketplaceSourceSchema())
 617          .optional()
 618          .describe(
 619            'Enterprise blocklist of marketplace sources. When set in managed settings, ' +
 620              'these exact sources are blocked from being added as marketplaces. The check happens BEFORE ' +
 621              'downloading, so blocked sources never touch the filesystem.',
 622          ),
 623        // Force a specific login method: 'claudeai' for Claude Pro/Max, 'console' for Console billing
 624        forceLoginMethod: z
 625          .enum(['claudeai', 'console'])
 626          .optional()
 627          .describe(
 628            'Force a specific login method: "claudeai" for Claude Pro/Max, "console" for Console billing',
 629          ),
 630        // Organization UUID to use for OAuth login (will be added as URL param to authorization URL)
 631        forceLoginOrgUUID: z
 632          .string()
 633          .optional()
 634          .describe('Organization UUID to use for OAuth login'),
 635        otelHeadersHelper: z
 636          .string()
 637          .optional()
 638          .describe('Path to a script that outputs OpenTelemetry headers'),
 639        outputStyle: z
 640          .string()
 641          .optional()
 642          .describe('Controls the output style for assistant responses'),
 643        language: z
 644          .string()
 645          .optional()
 646          .describe(
 647            'Preferred language for Claude responses and voice dictation (e.g., "japanese", "spanish")',
 648          ),
 649        skipWebFetchPreflight: z
 650          .boolean()
 651          .optional()
 652          .describe(
 653            'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies',
 654          ),
 655        sandbox: SandboxSettingsSchema().optional(),
 656        feedbackSurveyRate: z
 657          .number()
 658          .min(0)
 659          .max(1)
 660          .optional()
 661          .describe(
 662            'Probability (0–1) that the session quality survey appears when eligible. 0.05 is a reasonable starting point.',
 663          ),
 664        spinnerTipsEnabled: z
 665          .boolean()
 666          .optional()
 667          .describe('Whether to show tips in the spinner'),
 668        spinnerVerbs: z
 669          .object({
 670            mode: z.enum(['append', 'replace']),
 671            verbs: z.array(z.string()),
 672          })
 673          .optional()
 674          .describe(
 675            'Customize spinner verbs. mode: "append" adds verbs to defaults, "replace" uses only your verbs.',
 676          ),
 677        spinnerTipsOverride: z
 678          .object({
 679            excludeDefault: z.boolean().optional(),
 680            tips: z.array(z.string()),
 681          })
 682          .optional()
 683          .describe(
 684            'Override spinner tips. tips: array of tip strings. excludeDefault: if true, only show custom tips (default: false).',
 685          ),
 686        syntaxHighlightingDisabled: z
 687          .boolean()
 688          .optional()
 689          .describe('Whether to disable syntax highlighting in diffs'),
 690        terminalTitleFromRename: z
 691          .boolean()
 692          .optional()
 693          .describe(
 694            'Whether /rename updates the terminal tab title (defaults to true). Set to false to keep auto-generated topic titles.',
 695          ),
 696        alwaysThinkingEnabled: z
 697          .boolean()
 698          .optional()
 699          .describe(
 700            'When false, thinking is disabled. When absent or true, thinking is ' +
 701              'enabled automatically for supported models.',
 702          ),
 703        effortLevel: z
 704          .enum(
 705            process.env.USER_TYPE === 'ant'
 706              ? ['low', 'medium', 'high', 'max']
 707              : ['low', 'medium', 'high'],
 708          )
 709          .optional()
 710          .catch(undefined)
 711          .describe('Persisted effort level for supported models.'),
 712        advisorModel: z
 713          .string()
 714          .optional()
 715          .describe('Advisor model for the server-side advisor tool.'),
 716        fastMode: z
 717          .boolean()
 718          .optional()
 719          .describe(
 720            'When true, fast mode is enabled. When absent or false, fast mode is off.',
 721          ),
 722        fastModePerSessionOptIn: z
 723          .boolean()
 724          .optional()
 725          .describe(
 726            'When true, fast mode does not persist across sessions. Each session starts with fast mode off.',
 727          ),
 728        promptSuggestionEnabled: z
 729          .boolean()
 730          .optional()
 731          .describe(
 732            'When false, prompt suggestions are disabled. When absent or true, ' +
 733              'prompt suggestions are enabled.',
 734          ),
 735        showClearContextOnPlanAccept: z
 736          .boolean()
 737          .optional()
 738          .describe(
 739            'When true, the plan-approval dialog offers a "clear context" option. Defaults to false.',
 740          ),
 741        agent: z
 742          .string()
 743          .optional()
 744          .describe(
 745            'Name of an agent (built-in or custom) to use for the main thread. ' +
 746              "Applies the agent's system prompt, tool restrictions, and model.",
 747          ),
 748        companyAnnouncements: z
 749          .array(z.string())
 750          .optional()
 751          .describe(
 752            'Company announcements to display at startup (one will be randomly selected if multiple are provided)',
 753          ),
 754        pluginConfigs: z
 755          .record(
 756            z.string(),
 757            z.object({
 758              mcpServers: z
 759                .record(
 760                  z.string(),
 761                  z.record(
 762                    z.string(),
 763                    z.union([
 764                      z.string(),
 765                      z.number(),
 766                      z.boolean(),
 767                      z.array(z.string()),
 768                    ]),
 769                  ),
 770                )
 771                .optional()
 772                .describe(
 773                  'User configuration values for MCP servers keyed by server name',
 774                ),
 775              options: z
 776                .record(
 777                  z.string(),
 778                  z.union([
 779                    z.string(),
 780                    z.number(),
 781                    z.boolean(),
 782                    z.array(z.string()),
 783                  ]),
 784                )
 785                .optional()
 786                .describe(
 787                  'Non-sensitive option values from plugin manifest userConfig, keyed by option name. Sensitive values go to secure storage instead.',
 788                ),
 789            }),
 790          )
 791          .optional()
 792          .describe(
 793            'Per-plugin configuration including MCP server user configs, keyed by plugin ID (plugin@marketplace format)',
 794          ),
 795        remote: z
 796          .object({
 797            defaultEnvironmentId: z
 798              .string()
 799              .optional()
 800              .describe('Default environment ID to use for remote sessions'),
 801          })
 802          .optional()
 803          .describe('Remote session configuration'),
 804        autoUpdatesChannel: z
 805          .enum(['latest', 'stable'])
 806          .optional()
 807          .describe('Release channel for auto-updates (latest or stable)'),
 808        ...(feature('LODESTONE')
 809          ? {
 810              disableDeepLinkRegistration: z
 811                .enum(['disable'])
 812                .optional()
 813                .describe(
 814                  'Prevent claude-cli:// protocol handler registration with the OS',
 815                ),
 816            }
 817          : {}),
 818        minimumVersion: z
 819          .string()
 820          .optional()
 821          .describe(
 822            'Minimum version to stay on - prevents downgrades when switching to stable channel',
 823          ),
 824        plansDirectory: z
 825          .string()
 826          .optional()
 827          .describe(
 828            'Custom directory for plan files, relative to project root. ' +
 829              'If not set, defaults to ~/.claude/plans/',
 830          ),
 831        ...(process.env.USER_TYPE === 'ant'
 832          ? {
 833              classifierPermissionsEnabled: z
 834                .boolean()
 835                .optional()
 836                .describe(
 837                  'Enable AI-based classification for Bash(prompt:...) permission rules',
 838                ),
 839            }
 840          : {}),
 841        ...(feature('PROACTIVE') || feature('KAIROS')
 842          ? {
 843              minSleepDurationMs: z
 844                .number()
 845                .nonnegative()
 846                .int()
 847                .optional()
 848                .describe(
 849                  'Minimum duration in milliseconds that the Sleep tool must sleep for. ' +
 850                    'Useful for throttling proactive tick frequency.',
 851                ),
 852              maxSleepDurationMs: z
 853                .number()
 854                .int()
 855                .min(-1)
 856                .optional()
 857                .describe(
 858                  'Maximum duration in milliseconds that the Sleep tool can sleep for. ' +
 859                    'Set to -1 for indefinite sleep (waits for user input). ' +
 860                    'Useful for limiting idle time in remote/managed environments.',
 861                ),
 862            }
 863          : {}),
 864        ...(feature('VOICE_MODE')
 865          ? {
 866              voiceEnabled: z
 867                .boolean()
 868                .optional()
 869                .describe('Enable voice mode (hold-to-talk dictation)'),
 870            }
 871          : {}),
 872        ...(feature('KAIROS')
 873          ? {
 874              assistant: z
 875                .boolean()
 876                .optional()
 877                .describe(
 878                  'Start Claude in assistant mode (custom system prompt, brief view, scheduled check-in skills)',
 879                ),
 880              assistantName: z
 881                .string()
 882                .optional()
 883                .describe(
 884                  'Display name for the assistant, shown in the claude.ai session list',
 885                ),
 886            }
 887          : {}),
 888        // Teams/Enterprise opt-IN for channel notifications. Default OFF.
 889        // MCP servers that declare the claude/channel capability can push
 890        // inbound messages into the conversation; for managed orgs this only
 891        // works when explicitly enabled. Which servers can connect at all is
 892        // still governed by allowedMcpServers/deniedMcpServers. Not
 893        // feature-spread: KAIROS_CHANNELS is external:true, and the spread
 894        // wrecks type inference for allowedChannelPlugins (the .passthrough()
 895        // catch-all gives {} instead of the array type).
 896        channelsEnabled: z
 897          .boolean()
 898          .optional()
 899          .describe(
 900            'Teams/Enterprise opt-in for channel notifications (MCP servers with the ' +
 901              'claude/channel capability pushing inbound messages). Default off. ' +
 902              'Set true to allow; users then select servers via --channels.',
 903          ),
 904        // Org-level channel plugin allowlist. When set, REPLACES the
 905        // Anthropic ledger — admin owns the trust decision. Undefined means
 906        // fall back to the ledger. Plugin-only entry shape (same as the
 907        // ledger); server-kind entries still need the dev flag.
 908        allowedChannelPlugins: z
 909          .array(
 910            z.object({
 911              marketplace: z.string(),
 912              plugin: z.string(),
 913            }),
 914          )
 915          .optional()
 916          .describe(
 917            'Teams/Enterprise allowlist of channel plugins. When set, ' +
 918              'replaces the default Anthropic allowlist — admins decide which ' +
 919              'plugins may push inbound messages. Undefined falls back to the default. ' +
 920              'Requires channelsEnabled: true.',
 921          ),
 922        ...(feature('KAIROS') || feature('KAIROS_BRIEF')
 923          ? {
 924              defaultView: z
 925                .enum(['chat', 'transcript'])
 926                .optional()
 927                .describe(
 928                  'Default transcript view: chat (SendUserMessage checkpoints only) or transcript (full)',
 929                ),
 930            }
 931          : {}),
 932        prefersReducedMotion: z
 933          .boolean()
 934          .optional()
 935          .describe(
 936            'Reduce or disable animations for accessibility (spinner shimmer, flash effects, etc.)',
 937          ),
 938        autoMemoryEnabled: z
 939          .boolean()
 940          .optional()
 941          .describe(
 942            'Enable auto-memory for this project. When false, Claude will not read from or write to the auto-memory directory.',
 943          ),
 944        autoMemoryDirectory: z
 945          .string()
 946          .optional()
 947          .describe(
 948            '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/<sanitized-cwd>/memory/.',
 949          ),
 950        autoDreamEnabled: z
 951          .boolean()
 952          .optional()
 953          .describe(
 954            'Enable background memory consolidation (auto-dream). When set, overrides the server-side default.',
 955          ),
 956        showThinkingSummaries: z
 957          .boolean()
 958          .optional()
 959          .describe(
 960            'Show thinking summaries in the transcript view (ctrl+o). Default: false.',
 961          ),
 962        skipDangerousModePermissionPrompt: z
 963          .boolean()
 964          .optional()
 965          .describe(
 966            'Whether the user has accepted the bypass permissions mode dialog',
 967          ),
 968        ...(feature('TRANSCRIPT_CLASSIFIER')
 969          ? {
 970              skipAutoPermissionPrompt: z
 971                .boolean()
 972                .optional()
 973                .describe(
 974                  'Whether the user has accepted the auto mode opt-in dialog',
 975                ),
 976              useAutoModeDuringPlan: z
 977                .boolean()
 978                .optional()
 979                .describe(
 980                  'Whether plan mode uses auto mode semantics when auto mode is available (default: true)',
 981                ),
 982              autoMode: z
 983                .object({
 984                  allow: z
 985                    .array(z.string())
 986                    .optional()
 987                    .describe('Rules for the auto mode classifier allow section'),
 988                  soft_deny: z
 989                    .array(z.string())
 990                    .optional()
 991                    .describe('Rules for the auto mode classifier deny section'),
 992                  ...(process.env.USER_TYPE === 'ant'
 993                    ? {
 994                        // Back-compat alias for ant users; external users use soft_deny
 995                        deny: z.array(z.string()).optional(),
 996                      }
 997                    : {}),
 998                  environment: z
 999                    .array(z.string())
1000                    .optional()
1001                    .describe(
1002                      'Entries for the auto mode classifier environment section',
1003                    ),
1004                })
1005                .optional()
1006                .describe('Auto mode classifier prompt customization'),
1007            }
1008          : {}),
1009        disableAutoMode: z
1010          .enum(['disable'])
1011          .optional()
1012          .describe('Disable auto mode'),
1013        sshConfigs: z
1014          .array(
1015            z.object({
1016              id: z
1017                .string()
1018                .describe(
1019                  'Unique identifier for this SSH config. Used to match configs across settings sources.',
1020                ),
1021              name: z.string().describe('Display name for the SSH connection'),
1022              sshHost: z
1023                .string()
1024                .describe(
1025                  'SSH host in format "user@hostname" or "hostname", or a host alias from ~/.ssh/config',
1026                ),
1027              sshPort: z
1028                .number()
1029                .int()
1030                .optional()
1031                .describe('SSH port (default: 22)'),
1032              sshIdentityFile: z
1033                .string()
1034                .optional()
1035                .describe('Path to SSH identity file (private key)'),
1036              startDirectory: z
1037                .string()
1038                .optional()
1039                .describe(
1040                  'Default working directory on the remote host. ' +
1041                    'Supports tilde expansion (e.g. ~/projects). ' +
1042                    'If not specified, defaults to the remote user home directory. ' +
1043                    'Can be overridden by the [dir] positional argument in `claude ssh <config> [dir]`.',
1044                ),
1045            }),
1046          )
1047          .optional()
1048          .describe(
1049            'SSH connection configurations for remote environments. ' +
1050              'Typically set in managed settings by enterprise administrators ' +
1051              'to pre-configure SSH connections for team members.',
1052          ),
1053        claudeMdExcludes: z
1054          .array(z.string())
1055          .optional()
1056          .describe(
1057            'Glob patterns or absolute paths of CLAUDE.md files to exclude from loading. ' +
1058              'Patterns are matched against absolute file paths using picomatch. ' +
1059              'Only applies to User, Project, and Local memory types (Managed/policy files cannot be excluded). ' +
1060              'Examples: "/home/user/monorepo/CLAUDE.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"',
1061          ),
1062        pluginTrustMessage: z
1063          .string()
1064          .optional()
1065          .describe(
1066            'Custom message to append to the plugin trust warning shown before installation. ' +
1067              'Only read from policy settings (managed-settings.json / MDM). ' +
1068              'Useful for enterprise administrators to add organization-specific context ' +
1069              '(e.g., "All plugins from our internal marketplace are vetted and approved.").',
1070          ),
1071      })
1072      .passthrough(),
1073  )
1074  
1075  /**
1076   * Internal type for plugin hooks - includes plugin context for execution.
1077   * Not a Zod schema since it's not user-facing (plugins provide native hooks).
1078   */
1079  export type PluginHookMatcher = {
1080    matcher?: string
1081    hooks: HookCommand[]
1082    pluginRoot: string
1083    pluginName: string
1084    pluginId: string // format: "pluginName@marketplaceName"
1085  }
1086  
1087  /**
1088   * Internal type for skill hooks - includes skill context for execution.
1089   * Not a Zod schema since it's not user-facing (skills provide native hooks).
1090   */
1091  export type SkillHookMatcher = {
1092    matcher?: string
1093    hooks: HookCommand[]
1094    skillRoot: string
1095    skillName: string
1096  }
1097  
1098  export type AllowedMcpServerEntry = z.infer<
1099    ReturnType<typeof AllowedMcpServerEntrySchema>
1100  >
1101  export type DeniedMcpServerEntry = z.infer<
1102    ReturnType<typeof DeniedMcpServerEntrySchema>
1103  >
1104  export type SettingsJson = z.infer<ReturnType<typeof SettingsSchema>>
1105  
1106  /**
1107   * Type guard for MCP server entry with serverName
1108   */
1109  export function isMcpServerNameEntry(
1110    entry: AllowedMcpServerEntry | DeniedMcpServerEntry,
1111  ): entry is { serverName: string } {
1112    return 'serverName' in entry && entry.serverName !== undefined
1113  }
1114  
1115  /**
1116   * Type guard for MCP server entry with serverCommand
1117   */
1118  export function isMcpServerCommandEntry(
1119    entry: AllowedMcpServerEntry | DeniedMcpServerEntry,
1120  ): entry is { serverCommand: string[] } {
1121    return 'serverCommand' in entry && entry.serverCommand !== undefined
1122  }
1123  
1124  /**
1125   * Type guard for MCP server entry with serverUrl
1126   */
1127  export function isMcpServerUrlEntry(
1128    entry: AllowedMcpServerEntry | DeniedMcpServerEntry,
1129  ): entry is { serverUrl: string } {
1130    return 'serverUrl' in entry && entry.serverUrl !== undefined
1131  }
1132  
1133  /**
1134   * User configuration values for MCPB MCP servers
1135   */
1136  export type UserConfigValues = Record<
1137    string,
1138    string | number | boolean | string[]
1139  >
1140  
1141  /**
1142   * Plugin configuration stored in settings.json
1143   */
1144  export type PluginConfig = {
1145    mcpServers?: {
1146      [serverName: string]: UserConfigValues
1147    }
1148  }