/ utils / sandbox / sandbox-adapter.ts
sandbox-adapter.ts
  1  /**
  2   * Adapter layer that wraps @anthropic-ai/sandbox-runtime with Claude CLI-specific integrations.
  3   * This file provides the bridge between the external sandbox-runtime package and Claude CLI's
  4   * settings system, tool integration, and additional features.
  5   */
  6  
  7  import type {
  8    FsReadRestrictionConfig,
  9    FsWriteRestrictionConfig,
 10    IgnoreViolationsConfig,
 11    NetworkHostPattern,
 12    NetworkRestrictionConfig,
 13    SandboxAskCallback,
 14    SandboxDependencyCheck,
 15    SandboxRuntimeConfig,
 16    SandboxViolationEvent,
 17  } from '@anthropic-ai/sandbox-runtime'
 18  import {
 19    SandboxManager as BaseSandboxManager,
 20    SandboxRuntimeConfigSchema,
 21    SandboxViolationStore,
 22  } from '@anthropic-ai/sandbox-runtime'
 23  import { rmSync, statSync } from 'fs'
 24  import { readFile } from 'fs/promises'
 25  import { memoize } from 'lodash-es'
 26  import { join, resolve, sep } from 'path'
 27  import {
 28    getAdditionalDirectoriesForClaudeMd,
 29    getCwdState,
 30    getOriginalCwd,
 31  } from '../../bootstrap/state.js'
 32  import { logForDebugging } from '../debug.js'
 33  import { expandPath } from '../path.js'
 34  import { getPlatform, type Platform } from '../platform.js'
 35  import { settingsChangeDetector } from '../settings/changeDetector.js'
 36  import { SETTING_SOURCES, type SettingSource } from '../settings/constants.js'
 37  import { getManagedSettingsDropInDir } from '../settings/managedPath.js'
 38  import {
 39    getInitialSettings,
 40    getSettings_DEPRECATED,
 41    getSettingsFilePathForSource,
 42    getSettingsForSource,
 43    getSettingsRootPathForSource,
 44    updateSettingsForSource,
 45  } from '../settings/settings.js'
 46  import type { SettingsJson } from '../settings/types.js'
 47  
 48  // ============================================================================
 49  // Settings Converter
 50  // ============================================================================
 51  
 52  import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js'
 53  import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js'
 54  import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js'
 55  import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js'
 56  import { errorMessage } from '../errors.js'
 57  import { getClaudeTempDir } from '../permissions/filesystem.js'
 58  import type { PermissionRuleValue } from '../permissions/PermissionRule.js'
 59  import { ripgrepCommand } from '../ripgrep.js'
 60  
 61  // Local copies to avoid circular dependency
 62  // (permissions.ts imports SandboxManager, bashPermissions.ts imports permissions.ts)
 63  function permissionRuleValueFromString(
 64    ruleString: string,
 65  ): PermissionRuleValue {
 66    const matches = ruleString.match(/^([^(]+)\(([^)]+)\)$/)
 67    if (!matches) {
 68      return { toolName: ruleString }
 69    }
 70    const toolName = matches[1]
 71    const ruleContent = matches[2]
 72    if (!toolName || !ruleContent) {
 73      return { toolName: ruleString }
 74    }
 75    return { toolName, ruleContent }
 76  }
 77  
 78  function permissionRuleExtractPrefix(permissionRule: string): string | null {
 79    const match = permissionRule.match(/^(.+):\*$/)
 80    return match?.[1] ?? null
 81  }
 82  
 83  /**
 84   * Resolve Claude Code-specific path patterns for sandbox-runtime.
 85   *
 86   * Claude Code uses special path prefixes in permission rules:
 87   * - `//path` → absolute from filesystem root (becomes `/path`)
 88   * - `/path` → relative to settings file directory (becomes `$SETTINGS_DIR/path`)
 89   * - `~/path` → passed through (sandbox-runtime handles this)
 90   * - `./path` or `path` → passed through (sandbox-runtime handles this)
 91   *
 92   * This function only handles CC-specific conventions (`//` and `/`).
 93   * Standard path patterns like `~/` and relative paths are passed through
 94   * for sandbox-runtime's normalizePathForSandbox to handle.
 95   *
 96   * @param pattern The path pattern from a permission rule
 97   * @param source The settings source this pattern came from (needed to resolve `/path` patterns)
 98   */
 99  export function resolvePathPatternForSandbox(
100    pattern: string,
101    source: SettingSource,
102  ): string {
103    // Handle // prefix - absolute from root (CC-specific convention)
104    if (pattern.startsWith('//')) {
105      return pattern.slice(1) // "//.aws/**" → "/.aws/**"
106    }
107  
108    // Handle / prefix - relative to settings file directory (CC-specific convention)
109    // Note: ~/path and relative paths are passed through for sandbox-runtime to handle
110    if (pattern.startsWith('/') && !pattern.startsWith('//')) {
111      const root = getSettingsRootPathForSource(source)
112      // Pattern like "/foo/**" becomes "${root}/foo/**"
113      return resolve(root, pattern.slice(1))
114    }
115  
116    // Other patterns (~/path, ./path, path) pass through as-is
117    // sandbox-runtime's normalizePathForSandbox will handle them
118    return pattern
119  }
120  
121  /**
122   * Resolve paths from sandbox.filesystem.* settings (allowWrite, denyWrite, etc).
123   *
124   * Unlike permission rules (Edit/Read), these settings use standard path semantics:
125   * - `/path` → absolute path (as written, NOT settings-relative)
126   * - `~/path` → expanded to home directory
127   * - `./path` or `path` → relative to settings file directory
128   * - `//path` → absolute (legacy permission-rule syntax, accepted for compat)
129   *
130   * Fix for #30067: resolvePathPatternForSandbox treats `/Users/foo/.cargo` as
131   * settings-relative (permission-rule convention). Users reasonably expect
132   * absolute paths in sandbox.filesystem.allowWrite to work as-is.
133   *
134   * Also expands `~` here rather than relying on sandbox-runtime, because
135   * sandbox-runtime's getFsWriteConfig() does not call normalizePathForSandbox
136   * on allowWrite paths (it only strips trailing glob suffixes).
137   */
138  export function resolveSandboxFilesystemPath(
139    pattern: string,
140    source: SettingSource,
141  ): string {
142    // Legacy permission-rule escape: //path → /path. Kept for compat with
143    // users who worked around #30067 by writing //Users/foo/.cargo in config.
144    if (pattern.startsWith('//')) return pattern.slice(1)
145    return expandPath(pattern, getSettingsRootPathForSource(source))
146  }
147  
148  /**
149   * Check if only managed sandbox domains should be used.
150   * This is true when policySettings has sandbox.network.allowManagedDomainsOnly: true
151   */
152  export function shouldAllowManagedSandboxDomainsOnly(): boolean {
153    return (
154      getSettingsForSource('policySettings')?.sandbox?.network
155        ?.allowManagedDomainsOnly === true
156    )
157  }
158  
159  function shouldAllowManagedReadPathsOnly(): boolean {
160    return (
161      getSettingsForSource('policySettings')?.sandbox?.filesystem
162        ?.allowManagedReadPathsOnly === true
163    )
164  }
165  
166  /**
167   * Convert Claude Code settings format to SandboxRuntimeConfig format
168   * (Function exported for testing)
169   *
170   * @param settings Merged settings (used for sandbox config like network, ripgrep, etc.)
171   */
172  export function convertToSandboxRuntimeConfig(
173    settings: SettingsJson,
174  ): SandboxRuntimeConfig {
175    const permissions = settings.permissions || {}
176  
177    // Extract network domains from WebFetch rules
178    const allowedDomains: string[] = []
179    const deniedDomains: string[] = []
180  
181    // When allowManagedSandboxDomainsOnly is enabled, only use domains from policy settings
182    if (shouldAllowManagedSandboxDomainsOnly()) {
183      const policySettings = getSettingsForSource('policySettings')
184      for (const domain of policySettings?.sandbox?.network?.allowedDomains ||
185        []) {
186        allowedDomains.push(domain)
187      }
188      for (const ruleString of policySettings?.permissions?.allow || []) {
189        const rule = permissionRuleValueFromString(ruleString)
190        if (
191          rule.toolName === WEB_FETCH_TOOL_NAME &&
192          rule.ruleContent?.startsWith('domain:')
193        ) {
194          allowedDomains.push(rule.ruleContent.substring('domain:'.length))
195        }
196      }
197    } else {
198      for (const domain of settings.sandbox?.network?.allowedDomains || []) {
199        allowedDomains.push(domain)
200      }
201      for (const ruleString of permissions.allow || []) {
202        const rule = permissionRuleValueFromString(ruleString)
203        if (
204          rule.toolName === WEB_FETCH_TOOL_NAME &&
205          rule.ruleContent?.startsWith('domain:')
206        ) {
207          allowedDomains.push(rule.ruleContent.substring('domain:'.length))
208        }
209      }
210    }
211  
212    for (const ruleString of permissions.deny || []) {
213      const rule = permissionRuleValueFromString(ruleString)
214      if (
215        rule.toolName === WEB_FETCH_TOOL_NAME &&
216        rule.ruleContent?.startsWith('domain:')
217      ) {
218        deniedDomains.push(rule.ruleContent.substring('domain:'.length))
219      }
220    }
221  
222    // Extract filesystem paths from Edit and Read rules
223    // Always include current directory and Claude temp directory as writable
224    // The temp directory is needed for Shell.ts cwd tracking files
225    const allowWrite: string[] = ['.', getClaudeTempDir()]
226    const denyWrite: string[] = []
227    const denyRead: string[] = []
228    const allowRead: string[] = []
229  
230    // Always deny writes to settings.json files to prevent sandbox escape
231    // This blocks settings in the original working directory (where Claude Code started)
232    const settingsPaths = SETTING_SOURCES.map(source =>
233      getSettingsFilePathForSource(source),
234    ).filter((p): p is string => p !== undefined)
235    denyWrite.push(...settingsPaths)
236    denyWrite.push(getManagedSettingsDropInDir())
237  
238    // Also block settings files in the current working directory if it differs from original
239    // This handles the case where the user has cd'd to a different directory
240    const cwd = getCwdState()
241    const originalCwd = getOriginalCwd()
242    if (cwd !== originalCwd) {
243      denyWrite.push(resolve(cwd, '.claude', 'settings.json'))
244      denyWrite.push(resolve(cwd, '.claude', 'settings.local.json'))
245    }
246  
247    // Block writes to .claude/skills in both original and current working directories.
248    // The sandbox-runtime's getDangerousDirectories() protects .claude/commands and
249    // .claude/agents but not .claude/skills. Skills have the same privilege level
250    // (auto-discovered, auto-loaded, full Claude capabilities) so they need the
251    // same OS-level sandbox protection.
252    denyWrite.push(resolve(originalCwd, '.claude', 'skills'))
253    if (cwd !== originalCwd) {
254      denyWrite.push(resolve(cwd, '.claude', 'skills'))
255    }
256  
257    // SECURITY: Git's is_git_directory() treats cwd as a bare repo if it has
258    // HEAD + objects/ + refs/. An attacker planting these (plus a config with
259    // core.fsmonitor) escapes the sandbox when Claude's unsandboxed git runs.
260    //
261    // Unconditionally denying these paths makes sandbox-runtime mount
262    // /dev/null at non-existent ones, which (a) leaves a 0-byte HEAD stub on
263    // the host and (b) breaks `git log HEAD` inside bwrap ("ambiguous argument").
264    // So: if a file exists, denyWrite (ro-bind in place, no stub). If not, scrub
265    // it post-command in scrubBareGitRepoFiles() — planted files are gone before
266    // unsandboxed git runs; inside the command, git is itself sandboxed.
267    bareGitRepoScrubPaths.length = 0
268    const bareGitRepoFiles = ['HEAD', 'objects', 'refs', 'hooks', 'config']
269    for (const dir of cwd === originalCwd ? [originalCwd] : [originalCwd, cwd]) {
270      for (const gitFile of bareGitRepoFiles) {
271        const p = resolve(dir, gitFile)
272        try {
273          // eslint-disable-next-line custom-rules/no-sync-fs -- refreshConfig() must be sync
274          statSync(p)
275          denyWrite.push(p)
276        } catch {
277          bareGitRepoScrubPaths.push(p)
278        }
279      }
280    }
281  
282    // If we detected a git worktree during initialize(), the main repo path is
283    // cached in worktreeMainRepoPath. Git operations in a worktree need write
284    // access to the main repo's .git directory for index.lock etc.
285    // This is resolved once at init time (worktree status doesn't change mid-session).
286    if (worktreeMainRepoPath && worktreeMainRepoPath !== cwd) {
287      allowWrite.push(worktreeMainRepoPath)
288    }
289  
290    // Include directories added via --add-dir CLI flag or /add-dir command.
291    // These must be in allowWrite so that Bash commands (which run inside the
292    // sandbox) can access them — not just file tools, which check permissions
293    // at the app level via pathInAllowedWorkingPath().
294    // Two sources: persisted in settings, and session-only in bootstrap state.
295    const additionalDirs = new Set([
296      ...(settings.permissions?.additionalDirectories || []),
297      ...getAdditionalDirectoriesForClaudeMd(),
298    ])
299    allowWrite.push(...additionalDirs)
300  
301    // Iterate through each settings source to resolve paths correctly
302    // Path patterns like `/foo` are relative to the settings file directory,
303    // so we need to know which source each rule came from
304    for (const source of SETTING_SOURCES) {
305      const sourceSettings = getSettingsForSource(source)
306  
307      // Extract filesystem paths from permission rules
308      if (sourceSettings?.permissions) {
309        for (const ruleString of sourceSettings.permissions.allow || []) {
310          const rule = permissionRuleValueFromString(ruleString)
311          if (rule.toolName === FILE_EDIT_TOOL_NAME && rule.ruleContent) {
312            allowWrite.push(
313              resolvePathPatternForSandbox(rule.ruleContent, source),
314            )
315          }
316        }
317  
318        for (const ruleString of sourceSettings.permissions.deny || []) {
319          const rule = permissionRuleValueFromString(ruleString)
320          if (rule.toolName === FILE_EDIT_TOOL_NAME && rule.ruleContent) {
321            denyWrite.push(resolvePathPatternForSandbox(rule.ruleContent, source))
322          }
323          if (rule.toolName === FILE_READ_TOOL_NAME && rule.ruleContent) {
324            denyRead.push(resolvePathPatternForSandbox(rule.ruleContent, source))
325          }
326        }
327      }
328  
329      // Extract filesystem paths from sandbox.filesystem settings
330      // sandbox.filesystem.* uses standard path semantics (/path = absolute),
331      // NOT the permission-rule convention (/path = settings-relative). #30067
332      const fs = sourceSettings?.sandbox?.filesystem
333      if (fs) {
334        for (const p of fs.allowWrite || []) {
335          allowWrite.push(resolveSandboxFilesystemPath(p, source))
336        }
337        for (const p of fs.denyWrite || []) {
338          denyWrite.push(resolveSandboxFilesystemPath(p, source))
339        }
340        for (const p of fs.denyRead || []) {
341          denyRead.push(resolveSandboxFilesystemPath(p, source))
342        }
343        if (!shouldAllowManagedReadPathsOnly() || source === 'policySettings') {
344          for (const p of fs.allowRead || []) {
345            allowRead.push(resolveSandboxFilesystemPath(p, source))
346          }
347        }
348      }
349    }
350    // Ripgrep config for sandbox. User settings take priority; otherwise pass our rg.
351    // In embedded mode (argv0='rg' dispatch), sandbox-runtime spawns with argv0 set.
352    const { rgPath, rgArgs, argv0 } = ripgrepCommand()
353    const ripgrepConfig = settings.sandbox?.ripgrep ?? {
354      command: rgPath,
355      args: rgArgs,
356      argv0,
357    }
358  
359    return {
360      network: {
361        allowedDomains,
362        deniedDomains,
363        allowUnixSockets: settings.sandbox?.network?.allowUnixSockets,
364        allowAllUnixSockets: settings.sandbox?.network?.allowAllUnixSockets,
365        allowLocalBinding: settings.sandbox?.network?.allowLocalBinding,
366        httpProxyPort: settings.sandbox?.network?.httpProxyPort,
367        socksProxyPort: settings.sandbox?.network?.socksProxyPort,
368      },
369      filesystem: {
370        denyRead,
371        allowRead,
372        allowWrite,
373        denyWrite,
374      },
375      ignoreViolations: settings.sandbox?.ignoreViolations,
376      enableWeakerNestedSandbox: settings.sandbox?.enableWeakerNestedSandbox,
377      enableWeakerNetworkIsolation:
378        settings.sandbox?.enableWeakerNetworkIsolation,
379      ripgrep: ripgrepConfig,
380    }
381  }
382  
383  // ============================================================================
384  // Claude CLI-specific state
385  // ============================================================================
386  
387  let initializationPromise: Promise<void> | undefined
388  let settingsSubscriptionCleanup: (() => void) | undefined
389  
390  // Cached main repo path for git worktrees, resolved once during initialize().
391  // In a worktree, .git is a file containing "gitdir: /path/to/main/repo/.git/worktrees/name".
392  // undefined = not yet resolved; null = not a worktree or detection failed.
393  let worktreeMainRepoPath: string | null | undefined
394  
395  // Bare-repo files at cwd that didn't exist at config time and should be
396  // scrubbed if they appear after a sandboxed command. See anthropics/claude-code#29316.
397  const bareGitRepoScrubPaths: string[] = []
398  
399  /**
400   * Delete bare-repo files planted at cwd during a sandboxed command, before
401   * Claude's unsandboxed git calls can see them. See the SECURITY block above
402   * bareGitRepoFiles. anthropics/claude-code#29316.
403   */
404  function scrubBareGitRepoFiles(): void {
405    for (const p of bareGitRepoScrubPaths) {
406      try {
407        // eslint-disable-next-line custom-rules/no-sync-fs -- cleanupAfterCommand must be sync (Shell.ts:367)
408        rmSync(p, { recursive: true })
409        logForDebugging(`[Sandbox] scrubbed planted bare-repo file: ${p}`)
410      } catch {
411        // ENOENT is the expected common case — nothing was planted
412      }
413    }
414  }
415  
416  /**
417   * Detect if cwd is a git worktree and resolve the main repo path.
418   * Called once during initialize() and cached for the session.
419   * In a worktree, .git is a file (not a directory) containing "gitdir: ...".
420   * If .git is a directory, readFile throws EISDIR and we return null.
421   */
422  async function detectWorktreeMainRepoPath(cwd: string): Promise<string | null> {
423    const gitPath = join(cwd, '.git')
424    try {
425      const gitContent = await readFile(gitPath, { encoding: 'utf8' })
426      const gitdirMatch = gitContent.match(/^gitdir:\s*(.+)$/m)
427      if (!gitdirMatch?.[1]) {
428        return null
429      }
430      // gitdir may be relative (rare, but git accepts it) — resolve against cwd
431      const gitdir = resolve(cwd, gitdirMatch[1].trim())
432      // gitdir format: /path/to/main/repo/.git/worktrees/worktree-name
433      // Match the /.git/worktrees/ segment specifically — indexOf('.git') alone
434      // would false-match paths like /home/user/.github-projects/...
435      const marker = `${sep}.git${sep}worktrees${sep}`
436      const markerIndex = gitdir.lastIndexOf(marker)
437      if (markerIndex > 0) {
438        return gitdir.substring(0, markerIndex)
439      }
440      return null
441    } catch {
442      // Not in a worktree, .git is a directory (EISDIR), or can't read .git file
443      return null
444    }
445  }
446  
447  /**
448   * Check if dependencies are available (memoized)
449   * Returns { errors, warnings } - errors mean sandbox cannot run
450   */
451  const checkDependencies = memoize((): SandboxDependencyCheck => {
452    const { rgPath, rgArgs } = ripgrepCommand()
453    return BaseSandboxManager.checkDependencies({
454      command: rgPath,
455      args: rgArgs,
456    })
457  })
458  
459  function getSandboxEnabledSetting(): boolean {
460    try {
461      const settings = getSettings_DEPRECATED()
462      return settings?.sandbox?.enabled ?? false
463    } catch (error) {
464      logForDebugging(`Failed to get settings for sandbox check: ${error}`)
465      return false
466    }
467  }
468  
469  function isAutoAllowBashIfSandboxedEnabled(): boolean {
470    const settings = getSettings_DEPRECATED()
471    return settings?.sandbox?.autoAllowBashIfSandboxed ?? true
472  }
473  
474  function areUnsandboxedCommandsAllowed(): boolean {
475    const settings = getSettings_DEPRECATED()
476    return settings?.sandbox?.allowUnsandboxedCommands ?? true
477  }
478  
479  function isSandboxRequired(): boolean {
480    const settings = getSettings_DEPRECATED()
481    return (
482      getSandboxEnabledSetting() &&
483      (settings?.sandbox?.failIfUnavailable ?? false)
484    )
485  }
486  
487  /**
488   * Check if the current platform is supported for sandboxing (memoized)
489   * Supports: macOS, Linux, and WSL2+ (WSL1 is not supported)
490   */
491  const isSupportedPlatform = memoize((): boolean => {
492    return BaseSandboxManager.isSupportedPlatform()
493  })
494  
495  /**
496   * Check if the current platform is in the enabledPlatforms list.
497   *
498   * This is an undocumented setting that allows restricting sandbox to specific platforms.
499   * When enabledPlatforms is not set, all supported platforms are allowed.
500   *
501   * Added to unblock NVIDIA enterprise rollout: they want to enable autoAllowBashIfSandboxed
502   * but only on macOS initially, since Linux/WSL sandbox support is newer. This allows
503   * setting enabledPlatforms: ["macos"] to disable sandbox (and auto-allow) on other platforms.
504   */
505  function isPlatformInEnabledList(): boolean {
506    try {
507      const settings = getInitialSettings()
508      const enabledPlatforms = (
509        settings?.sandbox as { enabledPlatforms?: Platform[] } | undefined
510      )?.enabledPlatforms
511  
512      if (enabledPlatforms === undefined) {
513        return true
514      }
515  
516      if (enabledPlatforms.length === 0) {
517        return false
518      }
519  
520      const currentPlatform = getPlatform()
521      return enabledPlatforms.includes(currentPlatform)
522    } catch (error) {
523      logForDebugging(`Failed to check enabledPlatforms: ${error}`)
524      return true // Default to enabled if we can't read settings
525    }
526  }
527  
528  /**
529   * Check if sandboxing is enabled
530   * This checks the user's enabled setting, platform support, and enabledPlatforms restriction
531   */
532  function isSandboxingEnabled(): boolean {
533    if (!isSupportedPlatform()) {
534      return false
535    }
536  
537    if (checkDependencies().errors.length > 0) {
538      return false
539    }
540  
541    // Check if current platform is in the enabledPlatforms list (undocumented setting)
542    if (!isPlatformInEnabledList()) {
543      return false
544    }
545  
546    return getSandboxEnabledSetting()
547  }
548  
549  /**
550   * If the user explicitly enabled sandbox (sandbox.enabled: true in settings)
551   * but it cannot actually run, return a human-readable reason. Otherwise
552   * return undefined.
553   *
554   * Fix for #34044: previously isSandboxingEnabled() silently returned false
555   * when dependencies were missing, giving users zero feedback that their
556   * explicit security setting was being ignored. This is a security footgun —
557   * users configure allowedDomains expecting enforcement, get none.
558   *
559   * Call this once at startup (REPL/print) and surface the reason if present.
560   * Does not cover the case where the user never enabled sandbox (no noise).
561   */
562  function getSandboxUnavailableReason(): string | undefined {
563    // Only warn if user explicitly asked for sandbox. If they didn't enable
564    // it, missing deps are irrelevant.
565    if (!getSandboxEnabledSetting()) {
566      return undefined
567    }
568  
569    if (!isSupportedPlatform()) {
570      const platform = getPlatform()
571      if (platform === 'wsl') {
572        return 'sandbox.enabled is set but WSL1 is not supported (requires WSL2)'
573      }
574      return `sandbox.enabled is set but ${platform} is not supported (requires macOS, Linux, or WSL2)`
575    }
576  
577    if (!isPlatformInEnabledList()) {
578      return `sandbox.enabled is set but ${getPlatform()} is not in sandbox.enabledPlatforms`
579    }
580  
581    const deps = checkDependencies()
582    if (deps.errors.length > 0) {
583      const platform = getPlatform()
584      const hint =
585        platform === 'macos'
586          ? 'run /sandbox or /doctor for details'
587          : 'install missing tools (e.g. apt install bubblewrap socat) or run /sandbox for details'
588      return `sandbox.enabled is set but dependencies are missing: ${deps.errors.join(', ')} · ${hint}`
589    }
590  
591    return undefined
592  }
593  
594  /**
595   * Get glob patterns that won't work fully on Linux/WSL
596   */
597  function getLinuxGlobPatternWarnings(): string[] {
598    // Only return warnings on Linux/WSL (bubblewrap doesn't support globs)
599    const platform = getPlatform()
600    if (platform !== 'linux' && platform !== 'wsl') {
601      return []
602    }
603  
604    try {
605      const settings = getSettings_DEPRECATED()
606  
607      // Only return warnings when sandboxing is enabled (check settings directly, not cached value)
608      if (!settings?.sandbox?.enabled) {
609        return []
610      }
611  
612      const permissions = settings?.permissions || {}
613      const warnings: string[] = []
614  
615      // Helper to check if a path has glob characters (excluding trailing /**)
616      const hasGlobs = (path: string): boolean => {
617        const stripped = path.replace(/\/\*\*$/, '')
618        return /[*?[\]]/.test(stripped)
619      }
620  
621      // Check all permission rules
622      for (const ruleString of [
623        ...(permissions.allow || []),
624        ...(permissions.deny || []),
625      ]) {
626        const rule = permissionRuleValueFromString(ruleString)
627        if (
628          (rule.toolName === FILE_EDIT_TOOL_NAME ||
629            rule.toolName === FILE_READ_TOOL_NAME) &&
630          rule.ruleContent &&
631          hasGlobs(rule.ruleContent)
632        ) {
633          warnings.push(ruleString)
634        }
635      }
636  
637      return warnings
638    } catch (error) {
639      logForDebugging(`Failed to get Linux glob pattern warnings: ${error}`)
640      return []
641    }
642  }
643  
644  /**
645   * Check if sandbox settings are locked by policy
646   */
647  function areSandboxSettingsLockedByPolicy(): boolean {
648    // Check if sandbox settings are explicitly set in any source that overrides localSettings
649    // These sources have higher priority than localSettings and would make local changes ineffective
650    const overridingSources = ['flagSettings', 'policySettings'] as const
651  
652    for (const source of overridingSources) {
653      const settings = getSettingsForSource(source)
654      if (
655        settings?.sandbox?.enabled !== undefined ||
656        settings?.sandbox?.autoAllowBashIfSandboxed !== undefined ||
657        settings?.sandbox?.allowUnsandboxedCommands !== undefined
658      ) {
659        return true
660      }
661    }
662  
663    return false
664  }
665  
666  /**
667   * Set sandbox settings
668   */
669  async function setSandboxSettings(options: {
670    enabled?: boolean
671    autoAllowBashIfSandboxed?: boolean
672    allowUnsandboxedCommands?: boolean
673  }): Promise<void> {
674    const existingSettings = getSettingsForSource('localSettings')
675  
676    // Note: Memoized caches auto-invalidate when settings change because they use
677    // the settings object as the cache key (new settings object = cache miss)
678  
679    updateSettingsForSource('localSettings', {
680      sandbox: {
681        ...existingSettings?.sandbox,
682        ...(options.enabled !== undefined && { enabled: options.enabled }),
683        ...(options.autoAllowBashIfSandboxed !== undefined && {
684          autoAllowBashIfSandboxed: options.autoAllowBashIfSandboxed,
685        }),
686        ...(options.allowUnsandboxedCommands !== undefined && {
687          allowUnsandboxedCommands: options.allowUnsandboxedCommands,
688        }),
689      },
690    })
691  }
692  
693  /**
694   * Get excluded commands (commands that should not be sandboxed)
695   */
696  function getExcludedCommands(): string[] {
697    const settings = getSettings_DEPRECATED()
698    return settings?.sandbox?.excludedCommands ?? []
699  }
700  
701  /**
702   * Wrap command with sandbox, optionally specifying the shell to use
703   */
704  async function wrapWithSandbox(
705    command: string,
706    binShell?: string,
707    customConfig?: Partial<SandboxRuntimeConfig>,
708    abortSignal?: AbortSignal,
709  ): Promise<string> {
710    // If sandboxing is enabled, ensure initialization is complete
711    if (isSandboxingEnabled()) {
712      if (initializationPromise) {
713        await initializationPromise
714      } else {
715        throw new Error('Sandbox failed to initialize. ')
716      }
717    }
718  
719    return BaseSandboxManager.wrapWithSandbox(
720      command,
721      binShell,
722      customConfig,
723      abortSignal,
724    )
725  }
726  
727  /**
728   * Initialize sandbox with log monitoring enabled by default
729   */
730  async function initialize(
731    sandboxAskCallback?: SandboxAskCallback,
732  ): Promise<void> {
733    // If already initializing or initialized, return the promise
734    if (initializationPromise) {
735      return initializationPromise
736    }
737  
738    // Check if sandboxing is enabled in settings
739    if (!isSandboxingEnabled()) {
740      return
741    }
742  
743    // Wrap the callback to enforce allowManagedDomainsOnly policy.
744    // This ensures all code paths (REPL, print/SDK) are covered.
745    const wrappedCallback: SandboxAskCallback | undefined = sandboxAskCallback
746      ? async (hostPattern: NetworkHostPattern) => {
747          if (shouldAllowManagedSandboxDomainsOnly()) {
748            logForDebugging(
749              `[sandbox] Blocked network request to ${hostPattern.host} (allowManagedDomainsOnly)`,
750            )
751            return false
752          }
753          return sandboxAskCallback(hostPattern)
754        }
755      : undefined
756  
757    // Create the initialization promise synchronously (before any await) to prevent
758    // race conditions where wrapWithSandbox() is called before the promise is assigned.
759    initializationPromise = (async () => {
760      try {
761        // Resolve worktree main repo path once before building config.
762        // Worktree status doesn't change mid-session, so this is cached for all
763        // subsequent refreshConfig() calls (which must be synchronous to avoid
764        // race conditions where pending requests slip through with stale config).
765        if (worktreeMainRepoPath === undefined) {
766          worktreeMainRepoPath = await detectWorktreeMainRepoPath(getCwdState())
767        }
768  
769        const settings = getSettings_DEPRECATED()
770        const runtimeConfig = convertToSandboxRuntimeConfig(settings)
771  
772        // Log monitor is automatically enabled for macOS
773        await BaseSandboxManager.initialize(runtimeConfig, wrappedCallback)
774  
775        // Subscribe to settings changes to update sandbox config dynamically
776        settingsSubscriptionCleanup = settingsChangeDetector.subscribe(() => {
777          const settings = getSettings_DEPRECATED()
778          const newConfig = convertToSandboxRuntimeConfig(settings)
779          BaseSandboxManager.updateConfig(newConfig)
780          logForDebugging('Sandbox configuration updated from settings change')
781        })
782      } catch (error) {
783        // Clear the promise on error so initialization can be retried
784        initializationPromise = undefined
785  
786        // Log error but don't throw - let sandboxing fail gracefully
787        logForDebugging(`Failed to initialize sandbox: ${errorMessage(error)}`)
788      }
789    })()
790  
791    return initializationPromise
792  }
793  
794  /**
795   * Refresh sandbox config from current settings immediately
796   * Call this after updating permissions to avoid race conditions
797   */
798  function refreshConfig(): void {
799    if (!isSandboxingEnabled()) return
800    const settings = getSettings_DEPRECATED()
801    const newConfig = convertToSandboxRuntimeConfig(settings)
802    BaseSandboxManager.updateConfig(newConfig)
803  }
804  
805  /**
806   * Reset sandbox state and clear memoized values
807   */
808  async function reset(): Promise<void> {
809    // Clean up settings subscription
810    settingsSubscriptionCleanup?.()
811    settingsSubscriptionCleanup = undefined
812    worktreeMainRepoPath = undefined
813    bareGitRepoScrubPaths.length = 0
814  
815    // Clear memoized caches
816    checkDependencies.cache.clear?.()
817    isSupportedPlatform.cache.clear?.()
818    initializationPromise = undefined
819  
820    // Reset the base sandbox manager
821    return BaseSandboxManager.reset()
822  }
823  
824  /**
825   * Add a command to the excluded commands list (commands that should not be sandboxed)
826   * This is a Claude CLI-specific function that updates local settings.
827   */
828  export function addToExcludedCommands(
829    command: string,
830    permissionUpdates?: Array<{
831      type: string
832      rules: Array<{ toolName: string; ruleContent?: string }>
833    }>,
834  ): string {
835    const existingSettings = getSettingsForSource('localSettings')
836    const existingExcludedCommands =
837      existingSettings?.sandbox?.excludedCommands || []
838  
839    // Determine the command pattern to add
840    // If there are suggestions with Bash rules, extract the pattern (e.g., "npm run test" from "npm run test:*")
841    // Otherwise use the exact command
842    let commandPattern: string = command
843  
844    if (permissionUpdates) {
845      const bashSuggestions = permissionUpdates.filter(
846        update =>
847          update.type === 'addRules' &&
848          update.rules.some(rule => rule.toolName === BASH_TOOL_NAME),
849      )
850  
851      if (bashSuggestions.length > 0 && bashSuggestions[0]!.type === 'addRules') {
852        const firstBashRule = bashSuggestions[0]!.rules.find(
853          rule => rule.toolName === BASH_TOOL_NAME,
854        )
855        if (firstBashRule?.ruleContent) {
856          // Extract pattern from Bash(command) or Bash(command:*) format
857          const prefix = permissionRuleExtractPrefix(firstBashRule.ruleContent)
858          commandPattern = prefix || firstBashRule.ruleContent
859        }
860      }
861    }
862  
863    // Add to excludedCommands if not already present
864    if (!existingExcludedCommands.includes(commandPattern)) {
865      updateSettingsForSource('localSettings', {
866        sandbox: {
867          ...existingSettings?.sandbox,
868          excludedCommands: [...existingExcludedCommands, commandPattern],
869        },
870      })
871    }
872  
873    return commandPattern
874  }
875  
876  // ============================================================================
877  // Export interface and implementation
878  // ============================================================================
879  
880  export interface ISandboxManager {
881    initialize(sandboxAskCallback?: SandboxAskCallback): Promise<void>
882    isSupportedPlatform(): boolean
883    isPlatformInEnabledList(): boolean
884    getSandboxUnavailableReason(): string | undefined
885    isSandboxingEnabled(): boolean
886    isSandboxEnabledInSettings(): boolean
887    checkDependencies(): SandboxDependencyCheck
888    isAutoAllowBashIfSandboxedEnabled(): boolean
889    areUnsandboxedCommandsAllowed(): boolean
890    isSandboxRequired(): boolean
891    areSandboxSettingsLockedByPolicy(): boolean
892    setSandboxSettings(options: {
893      enabled?: boolean
894      autoAllowBashIfSandboxed?: boolean
895      allowUnsandboxedCommands?: boolean
896    }): Promise<void>
897    getFsReadConfig(): FsReadRestrictionConfig
898    getFsWriteConfig(): FsWriteRestrictionConfig
899    getNetworkRestrictionConfig(): NetworkRestrictionConfig
900    getAllowUnixSockets(): string[] | undefined
901    getAllowLocalBinding(): boolean | undefined
902    getIgnoreViolations(): IgnoreViolationsConfig | undefined
903    getEnableWeakerNestedSandbox(): boolean | undefined
904    getExcludedCommands(): string[]
905    getProxyPort(): number | undefined
906    getSocksProxyPort(): number | undefined
907    getLinuxHttpSocketPath(): string | undefined
908    getLinuxSocksSocketPath(): string | undefined
909    waitForNetworkInitialization(): Promise<boolean>
910    wrapWithSandbox(
911      command: string,
912      binShell?: string,
913      customConfig?: Partial<SandboxRuntimeConfig>,
914      abortSignal?: AbortSignal,
915    ): Promise<string>
916    cleanupAfterCommand(): void
917    getSandboxViolationStore(): SandboxViolationStore
918    annotateStderrWithSandboxFailures(command: string, stderr: string): string
919    getLinuxGlobPatternWarnings(): string[]
920    refreshConfig(): void
921    reset(): Promise<void>
922  }
923  
924  /**
925   * Claude CLI sandbox manager - wraps sandbox-runtime with Claude-specific features
926   */
927  export const SandboxManager: ISandboxManager = {
928    // Custom implementations
929    initialize,
930    isSandboxingEnabled,
931    isSandboxEnabledInSettings: getSandboxEnabledSetting,
932    isPlatformInEnabledList,
933    getSandboxUnavailableReason,
934    isAutoAllowBashIfSandboxedEnabled,
935    areUnsandboxedCommandsAllowed,
936    isSandboxRequired,
937    areSandboxSettingsLockedByPolicy,
938    setSandboxSettings,
939    getExcludedCommands,
940    wrapWithSandbox,
941    refreshConfig,
942    reset,
943    checkDependencies,
944  
945    // Forward to base sandbox manager
946    getFsReadConfig: BaseSandboxManager.getFsReadConfig,
947    getFsWriteConfig: BaseSandboxManager.getFsWriteConfig,
948    getNetworkRestrictionConfig: BaseSandboxManager.getNetworkRestrictionConfig,
949    getIgnoreViolations: BaseSandboxManager.getIgnoreViolations,
950    getLinuxGlobPatternWarnings,
951    isSupportedPlatform,
952    getAllowUnixSockets: BaseSandboxManager.getAllowUnixSockets,
953    getAllowLocalBinding: BaseSandboxManager.getAllowLocalBinding,
954    getEnableWeakerNestedSandbox: BaseSandboxManager.getEnableWeakerNestedSandbox,
955    getProxyPort: BaseSandboxManager.getProxyPort,
956    getSocksProxyPort: BaseSandboxManager.getSocksProxyPort,
957    getLinuxHttpSocketPath: BaseSandboxManager.getLinuxHttpSocketPath,
958    getLinuxSocksSocketPath: BaseSandboxManager.getLinuxSocksSocketPath,
959    waitForNetworkInitialization: BaseSandboxManager.waitForNetworkInitialization,
960    getSandboxViolationStore: BaseSandboxManager.getSandboxViolationStore,
961    annotateStderrWithSandboxFailures:
962      BaseSandboxManager.annotateStderrWithSandboxFailures,
963    cleanupAfterCommand: (): void => {
964      BaseSandboxManager.cleanupAfterCommand()
965      scrubBareGitRepoFiles()
966    },
967  }
968  
969  // ============================================================================
970  // Re-export types from sandbox-runtime
971  // ============================================================================
972  
973  export type {
974    SandboxAskCallback,
975    SandboxDependencyCheck,
976    FsReadRestrictionConfig,
977    FsWriteRestrictionConfig,
978    NetworkRestrictionConfig,
979    NetworkHostPattern,
980    SandboxViolationEvent,
981    SandboxRuntimeConfig,
982    IgnoreViolationsConfig,
983  }
984  
985  export { SandboxViolationStore, SandboxRuntimeConfigSchema }