/** * Adapter layer that wraps @anthropic-ai/sandbox-runtime with Claude CLI-specific integrations. * This file provides the bridge between the external sandbox-runtime package and Claude CLI's * settings system, tool integration, and additional features. */ import type { FsReadRestrictionConfig, FsWriteRestrictionConfig, IgnoreViolationsConfig, NetworkHostPattern, NetworkRestrictionConfig, SandboxAskCallback, SandboxDependencyCheck, SandboxRuntimeConfig, SandboxViolationEvent, } from '@anthropic-ai/sandbox-runtime' import { SandboxManager as BaseSandboxManager, SandboxRuntimeConfigSchema, SandboxViolationStore, } from '@anthropic-ai/sandbox-runtime' import { rmSync, statSync } from 'fs' import { readFile } from 'fs/promises' import { memoize } from 'lodash-es' import { join, resolve, sep } from 'path' import { getAdditionalDirectoriesForClaudeMd, getCwdState, getOriginalCwd, } from '../../bootstrap/state.js' import { logForDebugging } from '../debug.js' import { expandPath } from '../path.js' import { getPlatform, type Platform } from '../platform.js' import { settingsChangeDetector } from '../settings/changeDetector.js' import { SETTING_SOURCES, type SettingSource } from '../settings/constants.js' import { getManagedSettingsDropInDir } from '../settings/managedPath.js' import { getInitialSettings, getSettings_DEPRECATED, getSettingsFilePathForSource, getSettingsForSource, getSettingsRootPathForSource, updateSettingsForSource, } from '../settings/settings.js' import type { SettingsJson } from '../settings/types.js' // ============================================================================ // Settings Converter // ============================================================================ import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js' import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js' import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js' import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js' import { errorMessage } from '../errors.js' import { getClaudeTempDir } from '../permissions/filesystem.js' import type { PermissionRuleValue } from '../permissions/PermissionRule.js' import { ripgrepCommand } from '../ripgrep.js' // Local copies to avoid circular dependency // (permissions.ts imports SandboxManager, bashPermissions.ts imports permissions.ts) function permissionRuleValueFromString( ruleString: string, ): PermissionRuleValue { const matches = ruleString.match(/^([^(]+)\(([^)]+)\)$/) if (!matches) { return { toolName: ruleString } } const toolName = matches[1] const ruleContent = matches[2] if (!toolName || !ruleContent) { return { toolName: ruleString } } return { toolName, ruleContent } } function permissionRuleExtractPrefix(permissionRule: string): string | null { const match = permissionRule.match(/^(.+):\*$/) return match?.[1] ?? null } /** * Resolve Claude Code-specific path patterns for sandbox-runtime. * * Claude Code uses special path prefixes in permission rules: * - `//path` → absolute from filesystem root (becomes `/path`) * - `/path` → relative to settings file directory (becomes `$SETTINGS_DIR/path`) * - `~/path` → passed through (sandbox-runtime handles this) * - `./path` or `path` → passed through (sandbox-runtime handles this) * * This function only handles CC-specific conventions (`//` and `/`). * Standard path patterns like `~/` and relative paths are passed through * for sandbox-runtime's normalizePathForSandbox to handle. * * @param pattern The path pattern from a permission rule * @param source The settings source this pattern came from (needed to resolve `/path` patterns) */ export function resolvePathPatternForSandbox( pattern: string, source: SettingSource, ): string { // Handle // prefix - absolute from root (CC-specific convention) if (pattern.startsWith('//')) { return pattern.slice(1) // "//.aws/**" → "/.aws/**" } // Handle / prefix - relative to settings file directory (CC-specific convention) // Note: ~/path and relative paths are passed through for sandbox-runtime to handle if (pattern.startsWith('/') && !pattern.startsWith('//')) { const root = getSettingsRootPathForSource(source) // Pattern like "/foo/**" becomes "${root}/foo/**" return resolve(root, pattern.slice(1)) } // Other patterns (~/path, ./path, path) pass through as-is // sandbox-runtime's normalizePathForSandbox will handle them return pattern } /** * Resolve paths from sandbox.filesystem.* settings (allowWrite, denyWrite, etc). * * Unlike permission rules (Edit/Read), these settings use standard path semantics: * - `/path` → absolute path (as written, NOT settings-relative) * - `~/path` → expanded to home directory * - `./path` or `path` → relative to settings file directory * - `//path` → absolute (legacy permission-rule syntax, accepted for compat) * * Fix for #30067: resolvePathPatternForSandbox treats `/Users/foo/.cargo` as * settings-relative (permission-rule convention). Users reasonably expect * absolute paths in sandbox.filesystem.allowWrite to work as-is. * * Also expands `~` here rather than relying on sandbox-runtime, because * sandbox-runtime's getFsWriteConfig() does not call normalizePathForSandbox * on allowWrite paths (it only strips trailing glob suffixes). */ export function resolveSandboxFilesystemPath( pattern: string, source: SettingSource, ): string { // Legacy permission-rule escape: //path → /path. Kept for compat with // users who worked around #30067 by writing //Users/foo/.cargo in config. if (pattern.startsWith('//')) return pattern.slice(1) return expandPath(pattern, getSettingsRootPathForSource(source)) } /** * Check if only managed sandbox domains should be used. * This is true when policySettings has sandbox.network.allowManagedDomainsOnly: true */ export function shouldAllowManagedSandboxDomainsOnly(): boolean { return ( getSettingsForSource('policySettings')?.sandbox?.network ?.allowManagedDomainsOnly === true ) } function shouldAllowManagedReadPathsOnly(): boolean { return ( getSettingsForSource('policySettings')?.sandbox?.filesystem ?.allowManagedReadPathsOnly === true ) } /** * Convert Claude Code settings format to SandboxRuntimeConfig format * (Function exported for testing) * * @param settings Merged settings (used for sandbox config like network, ripgrep, etc.) */ export function convertToSandboxRuntimeConfig( settings: SettingsJson, ): SandboxRuntimeConfig { const permissions = settings.permissions || {} // Extract network domains from WebFetch rules const allowedDomains: string[] = [] const deniedDomains: string[] = [] // When allowManagedSandboxDomainsOnly is enabled, only use domains from policy settings if (shouldAllowManagedSandboxDomainsOnly()) { const policySettings = getSettingsForSource('policySettings') for (const domain of policySettings?.sandbox?.network?.allowedDomains || []) { allowedDomains.push(domain) } for (const ruleString of policySettings?.permissions?.allow || []) { const rule = permissionRuleValueFromString(ruleString) if ( rule.toolName === WEB_FETCH_TOOL_NAME && rule.ruleContent?.startsWith('domain:') ) { allowedDomains.push(rule.ruleContent.substring('domain:'.length)) } } } else { for (const domain of settings.sandbox?.network?.allowedDomains || []) { allowedDomains.push(domain) } for (const ruleString of permissions.allow || []) { const rule = permissionRuleValueFromString(ruleString) if ( rule.toolName === WEB_FETCH_TOOL_NAME && rule.ruleContent?.startsWith('domain:') ) { allowedDomains.push(rule.ruleContent.substring('domain:'.length)) } } } for (const ruleString of permissions.deny || []) { const rule = permissionRuleValueFromString(ruleString) if ( rule.toolName === WEB_FETCH_TOOL_NAME && rule.ruleContent?.startsWith('domain:') ) { deniedDomains.push(rule.ruleContent.substring('domain:'.length)) } } // Extract filesystem paths from Edit and Read rules // Always include current directory and Claude temp directory as writable // The temp directory is needed for Shell.ts cwd tracking files const allowWrite: string[] = ['.', getClaudeTempDir()] const denyWrite: string[] = [] const denyRead: string[] = [] const allowRead: string[] = [] // Always deny writes to settings.json files to prevent sandbox escape // This blocks settings in the original working directory (where Claude Code started) const settingsPaths = SETTING_SOURCES.map(source => getSettingsFilePathForSource(source), ).filter((p): p is string => p !== undefined) denyWrite.push(...settingsPaths) denyWrite.push(getManagedSettingsDropInDir()) // Also block settings files in the current working directory if it differs from original // This handles the case where the user has cd'd to a different directory const cwd = getCwdState() const originalCwd = getOriginalCwd() if (cwd !== originalCwd) { denyWrite.push(resolve(cwd, '.claude', 'settings.json')) denyWrite.push(resolve(cwd, '.claude', 'settings.local.json')) } // Block writes to .claude/skills in both original and current working directories. // The sandbox-runtime's getDangerousDirectories() protects .claude/commands and // .claude/agents but not .claude/skills. Skills have the same privilege level // (auto-discovered, auto-loaded, full Claude capabilities) so they need the // same OS-level sandbox protection. denyWrite.push(resolve(originalCwd, '.claude', 'skills')) if (cwd !== originalCwd) { denyWrite.push(resolve(cwd, '.claude', 'skills')) } // SECURITY: Git's is_git_directory() treats cwd as a bare repo if it has // HEAD + objects/ + refs/. An attacker planting these (plus a config with // core.fsmonitor) escapes the sandbox when Claude's unsandboxed git runs. // // Unconditionally denying these paths makes sandbox-runtime mount // /dev/null at non-existent ones, which (a) leaves a 0-byte HEAD stub on // the host and (b) breaks `git log HEAD` inside bwrap ("ambiguous argument"). // So: if a file exists, denyWrite (ro-bind in place, no stub). If not, scrub // it post-command in scrubBareGitRepoFiles() — planted files are gone before // unsandboxed git runs; inside the command, git is itself sandboxed. bareGitRepoScrubPaths.length = 0 const bareGitRepoFiles = ['HEAD', 'objects', 'refs', 'hooks', 'config'] for (const dir of cwd === originalCwd ? [originalCwd] : [originalCwd, cwd]) { for (const gitFile of bareGitRepoFiles) { const p = resolve(dir, gitFile) try { // eslint-disable-next-line custom-rules/no-sync-fs -- refreshConfig() must be sync statSync(p) denyWrite.push(p) } catch { bareGitRepoScrubPaths.push(p) } } } // If we detected a git worktree during initialize(), the main repo path is // cached in worktreeMainRepoPath. Git operations in a worktree need write // access to the main repo's .git directory for index.lock etc. // This is resolved once at init time (worktree status doesn't change mid-session). if (worktreeMainRepoPath && worktreeMainRepoPath !== cwd) { allowWrite.push(worktreeMainRepoPath) } // Include directories added via --add-dir CLI flag or /add-dir command. // These must be in allowWrite so that Bash commands (which run inside the // sandbox) can access them — not just file tools, which check permissions // at the app level via pathInAllowedWorkingPath(). // Two sources: persisted in settings, and session-only in bootstrap state. const additionalDirs = new Set([ ...(settings.permissions?.additionalDirectories || []), ...getAdditionalDirectoriesForClaudeMd(), ]) allowWrite.push(...additionalDirs) // Iterate through each settings source to resolve paths correctly // Path patterns like `/foo` are relative to the settings file directory, // so we need to know which source each rule came from for (const source of SETTING_SOURCES) { const sourceSettings = getSettingsForSource(source) // Extract filesystem paths from permission rules if (sourceSettings?.permissions) { for (const ruleString of sourceSettings.permissions.allow || []) { const rule = permissionRuleValueFromString(ruleString) if (rule.toolName === FILE_EDIT_TOOL_NAME && rule.ruleContent) { allowWrite.push( resolvePathPatternForSandbox(rule.ruleContent, source), ) } } for (const ruleString of sourceSettings.permissions.deny || []) { const rule = permissionRuleValueFromString(ruleString) if (rule.toolName === FILE_EDIT_TOOL_NAME && rule.ruleContent) { denyWrite.push(resolvePathPatternForSandbox(rule.ruleContent, source)) } if (rule.toolName === FILE_READ_TOOL_NAME && rule.ruleContent) { denyRead.push(resolvePathPatternForSandbox(rule.ruleContent, source)) } } } // Extract filesystem paths from sandbox.filesystem settings // sandbox.filesystem.* uses standard path semantics (/path = absolute), // NOT the permission-rule convention (/path = settings-relative). #30067 const fs = sourceSettings?.sandbox?.filesystem if (fs) { for (const p of fs.allowWrite || []) { allowWrite.push(resolveSandboxFilesystemPath(p, source)) } for (const p of fs.denyWrite || []) { denyWrite.push(resolveSandboxFilesystemPath(p, source)) } for (const p of fs.denyRead || []) { denyRead.push(resolveSandboxFilesystemPath(p, source)) } if (!shouldAllowManagedReadPathsOnly() || source === 'policySettings') { for (const p of fs.allowRead || []) { allowRead.push(resolveSandboxFilesystemPath(p, source)) } } } } // Ripgrep config for sandbox. User settings take priority; otherwise pass our rg. // In embedded mode (argv0='rg' dispatch), sandbox-runtime spawns with argv0 set. const { rgPath, rgArgs, argv0 } = ripgrepCommand() const ripgrepConfig = settings.sandbox?.ripgrep ?? { command: rgPath, args: rgArgs, argv0, } return { network: { allowedDomains, deniedDomains, allowUnixSockets: settings.sandbox?.network?.allowUnixSockets, allowAllUnixSockets: settings.sandbox?.network?.allowAllUnixSockets, allowLocalBinding: settings.sandbox?.network?.allowLocalBinding, httpProxyPort: settings.sandbox?.network?.httpProxyPort, socksProxyPort: settings.sandbox?.network?.socksProxyPort, }, filesystem: { denyRead, allowRead, allowWrite, denyWrite, }, ignoreViolations: settings.sandbox?.ignoreViolations, enableWeakerNestedSandbox: settings.sandbox?.enableWeakerNestedSandbox, enableWeakerNetworkIsolation: settings.sandbox?.enableWeakerNetworkIsolation, ripgrep: ripgrepConfig, } } // ============================================================================ // Claude CLI-specific state // ============================================================================ let initializationPromise: Promise | undefined let settingsSubscriptionCleanup: (() => void) | undefined // Cached main repo path for git worktrees, resolved once during initialize(). // In a worktree, .git is a file containing "gitdir: /path/to/main/repo/.git/worktrees/name". // undefined = not yet resolved; null = not a worktree or detection failed. let worktreeMainRepoPath: string | null | undefined // Bare-repo files at cwd that didn't exist at config time and should be // scrubbed if they appear after a sandboxed command. See anthropics/claude-code#29316. const bareGitRepoScrubPaths: string[] = [] /** * Delete bare-repo files planted at cwd during a sandboxed command, before * Claude's unsandboxed git calls can see them. See the SECURITY block above * bareGitRepoFiles. anthropics/claude-code#29316. */ function scrubBareGitRepoFiles(): void { for (const p of bareGitRepoScrubPaths) { try { // eslint-disable-next-line custom-rules/no-sync-fs -- cleanupAfterCommand must be sync (Shell.ts:367) rmSync(p, { recursive: true }) logForDebugging(`[Sandbox] scrubbed planted bare-repo file: ${p}`) } catch { // ENOENT is the expected common case — nothing was planted } } } /** * Detect if cwd is a git worktree and resolve the main repo path. * Called once during initialize() and cached for the session. * In a worktree, .git is a file (not a directory) containing "gitdir: ...". * If .git is a directory, readFile throws EISDIR and we return null. */ async function detectWorktreeMainRepoPath(cwd: string): Promise { const gitPath = join(cwd, '.git') try { const gitContent = await readFile(gitPath, { encoding: 'utf8' }) const gitdirMatch = gitContent.match(/^gitdir:\s*(.+)$/m) if (!gitdirMatch?.[1]) { return null } // gitdir may be relative (rare, but git accepts it) — resolve against cwd const gitdir = resolve(cwd, gitdirMatch[1].trim()) // gitdir format: /path/to/main/repo/.git/worktrees/worktree-name // Match the /.git/worktrees/ segment specifically — indexOf('.git') alone // would false-match paths like /home/user/.github-projects/... const marker = `${sep}.git${sep}worktrees${sep}` const markerIndex = gitdir.lastIndexOf(marker) if (markerIndex > 0) { return gitdir.substring(0, markerIndex) } return null } catch { // Not in a worktree, .git is a directory (EISDIR), or can't read .git file return null } } /** * Check if dependencies are available (memoized) * Returns { errors, warnings } - errors mean sandbox cannot run */ const checkDependencies = memoize((): SandboxDependencyCheck => { const { rgPath, rgArgs } = ripgrepCommand() return BaseSandboxManager.checkDependencies({ command: rgPath, args: rgArgs, }) }) function getSandboxEnabledSetting(): boolean { try { const settings = getSettings_DEPRECATED() return settings?.sandbox?.enabled ?? false } catch (error) { logForDebugging(`Failed to get settings for sandbox check: ${error}`) return false } } function isAutoAllowBashIfSandboxedEnabled(): boolean { const settings = getSettings_DEPRECATED() return settings?.sandbox?.autoAllowBashIfSandboxed ?? true } function areUnsandboxedCommandsAllowed(): boolean { const settings = getSettings_DEPRECATED() return settings?.sandbox?.allowUnsandboxedCommands ?? true } function isSandboxRequired(): boolean { const settings = getSettings_DEPRECATED() return ( getSandboxEnabledSetting() && (settings?.sandbox?.failIfUnavailable ?? false) ) } /** * Check if the current platform is supported for sandboxing (memoized) * Supports: macOS, Linux, and WSL2+ (WSL1 is not supported) */ const isSupportedPlatform = memoize((): boolean => { return BaseSandboxManager.isSupportedPlatform() }) /** * Check if the current platform is in the enabledPlatforms list. * * This is an undocumented setting that allows restricting sandbox to specific platforms. * When enabledPlatforms is not set, all supported platforms are allowed. * * Added to unblock NVIDIA enterprise rollout: they want to enable autoAllowBashIfSandboxed * but only on macOS initially, since Linux/WSL sandbox support is newer. This allows * setting enabledPlatforms: ["macos"] to disable sandbox (and auto-allow) on other platforms. */ function isPlatformInEnabledList(): boolean { try { const settings = getInitialSettings() const enabledPlatforms = ( settings?.sandbox as { enabledPlatforms?: Platform[] } | undefined )?.enabledPlatforms if (enabledPlatforms === undefined) { return true } if (enabledPlatforms.length === 0) { return false } const currentPlatform = getPlatform() return enabledPlatforms.includes(currentPlatform) } catch (error) { logForDebugging(`Failed to check enabledPlatforms: ${error}`) return true // Default to enabled if we can't read settings } } /** * Check if sandboxing is enabled * This checks the user's enabled setting, platform support, and enabledPlatforms restriction */ function isSandboxingEnabled(): boolean { if (!isSupportedPlatform()) { return false } if (checkDependencies().errors.length > 0) { return false } // Check if current platform is in the enabledPlatforms list (undocumented setting) if (!isPlatformInEnabledList()) { return false } return getSandboxEnabledSetting() } /** * If the user explicitly enabled sandbox (sandbox.enabled: true in settings) * but it cannot actually run, return a human-readable reason. Otherwise * return undefined. * * Fix for #34044: previously isSandboxingEnabled() silently returned false * when dependencies were missing, giving users zero feedback that their * explicit security setting was being ignored. This is a security footgun — * users configure allowedDomains expecting enforcement, get none. * * Call this once at startup (REPL/print) and surface the reason if present. * Does not cover the case where the user never enabled sandbox (no noise). */ function getSandboxUnavailableReason(): string | undefined { // Only warn if user explicitly asked for sandbox. If they didn't enable // it, missing deps are irrelevant. if (!getSandboxEnabledSetting()) { return undefined } if (!isSupportedPlatform()) { const platform = getPlatform() if (platform === 'wsl') { return 'sandbox.enabled is set but WSL1 is not supported (requires WSL2)' } return `sandbox.enabled is set but ${platform} is not supported (requires macOS, Linux, or WSL2)` } if (!isPlatformInEnabledList()) { return `sandbox.enabled is set but ${getPlatform()} is not in sandbox.enabledPlatforms` } const deps = checkDependencies() if (deps.errors.length > 0) { const platform = getPlatform() const hint = platform === 'macos' ? 'run /sandbox or /doctor for details' : 'install missing tools (e.g. apt install bubblewrap socat) or run /sandbox for details' return `sandbox.enabled is set but dependencies are missing: ${deps.errors.join(', ')} · ${hint}` } return undefined } /** * Get glob patterns that won't work fully on Linux/WSL */ function getLinuxGlobPatternWarnings(): string[] { // Only return warnings on Linux/WSL (bubblewrap doesn't support globs) const platform = getPlatform() if (platform !== 'linux' && platform !== 'wsl') { return [] } try { const settings = getSettings_DEPRECATED() // Only return warnings when sandboxing is enabled (check settings directly, not cached value) if (!settings?.sandbox?.enabled) { return [] } const permissions = settings?.permissions || {} const warnings: string[] = [] // Helper to check if a path has glob characters (excluding trailing /**) const hasGlobs = (path: string): boolean => { const stripped = path.replace(/\/\*\*$/, '') return /[*?[\]]/.test(stripped) } // Check all permission rules for (const ruleString of [ ...(permissions.allow || []), ...(permissions.deny || []), ]) { const rule = permissionRuleValueFromString(ruleString) if ( (rule.toolName === FILE_EDIT_TOOL_NAME || rule.toolName === FILE_READ_TOOL_NAME) && rule.ruleContent && hasGlobs(rule.ruleContent) ) { warnings.push(ruleString) } } return warnings } catch (error) { logForDebugging(`Failed to get Linux glob pattern warnings: ${error}`) return [] } } /** * Check if sandbox settings are locked by policy */ function areSandboxSettingsLockedByPolicy(): boolean { // Check if sandbox settings are explicitly set in any source that overrides localSettings // These sources have higher priority than localSettings and would make local changes ineffective const overridingSources = ['flagSettings', 'policySettings'] as const for (const source of overridingSources) { const settings = getSettingsForSource(source) if ( settings?.sandbox?.enabled !== undefined || settings?.sandbox?.autoAllowBashIfSandboxed !== undefined || settings?.sandbox?.allowUnsandboxedCommands !== undefined ) { return true } } return false } /** * Set sandbox settings */ async function setSandboxSettings(options: { enabled?: boolean autoAllowBashIfSandboxed?: boolean allowUnsandboxedCommands?: boolean }): Promise { const existingSettings = getSettingsForSource('localSettings') // Note: Memoized caches auto-invalidate when settings change because they use // the settings object as the cache key (new settings object = cache miss) updateSettingsForSource('localSettings', { sandbox: { ...existingSettings?.sandbox, ...(options.enabled !== undefined && { enabled: options.enabled }), ...(options.autoAllowBashIfSandboxed !== undefined && { autoAllowBashIfSandboxed: options.autoAllowBashIfSandboxed, }), ...(options.allowUnsandboxedCommands !== undefined && { allowUnsandboxedCommands: options.allowUnsandboxedCommands, }), }, }) } /** * Get excluded commands (commands that should not be sandboxed) */ function getExcludedCommands(): string[] { const settings = getSettings_DEPRECATED() return settings?.sandbox?.excludedCommands ?? [] } /** * Wrap command with sandbox, optionally specifying the shell to use */ async function wrapWithSandbox( command: string, binShell?: string, customConfig?: Partial, abortSignal?: AbortSignal, ): Promise { // If sandboxing is enabled, ensure initialization is complete if (isSandboxingEnabled()) { if (initializationPromise) { await initializationPromise } else { throw new Error('Sandbox failed to initialize. ') } } return BaseSandboxManager.wrapWithSandbox( command, binShell, customConfig, abortSignal, ) } /** * Initialize sandbox with log monitoring enabled by default */ async function initialize( sandboxAskCallback?: SandboxAskCallback, ): Promise { // If already initializing or initialized, return the promise if (initializationPromise) { return initializationPromise } // Check if sandboxing is enabled in settings if (!isSandboxingEnabled()) { return } // Wrap the callback to enforce allowManagedDomainsOnly policy. // This ensures all code paths (REPL, print/SDK) are covered. const wrappedCallback: SandboxAskCallback | undefined = sandboxAskCallback ? async (hostPattern: NetworkHostPattern) => { if (shouldAllowManagedSandboxDomainsOnly()) { logForDebugging( `[sandbox] Blocked network request to ${hostPattern.host} (allowManagedDomainsOnly)`, ) return false } return sandboxAskCallback(hostPattern) } : undefined // Create the initialization promise synchronously (before any await) to prevent // race conditions where wrapWithSandbox() is called before the promise is assigned. initializationPromise = (async () => { try { // Resolve worktree main repo path once before building config. // Worktree status doesn't change mid-session, so this is cached for all // subsequent refreshConfig() calls (which must be synchronous to avoid // race conditions where pending requests slip through with stale config). if (worktreeMainRepoPath === undefined) { worktreeMainRepoPath = await detectWorktreeMainRepoPath(getCwdState()) } const settings = getSettings_DEPRECATED() const runtimeConfig = convertToSandboxRuntimeConfig(settings) // Log monitor is automatically enabled for macOS await BaseSandboxManager.initialize(runtimeConfig, wrappedCallback) // Subscribe to settings changes to update sandbox config dynamically settingsSubscriptionCleanup = settingsChangeDetector.subscribe(() => { const settings = getSettings_DEPRECATED() const newConfig = convertToSandboxRuntimeConfig(settings) BaseSandboxManager.updateConfig(newConfig) logForDebugging('Sandbox configuration updated from settings change') }) } catch (error) { // Clear the promise on error so initialization can be retried initializationPromise = undefined // Log error but don't throw - let sandboxing fail gracefully logForDebugging(`Failed to initialize sandbox: ${errorMessage(error)}`) } })() return initializationPromise } /** * Refresh sandbox config from current settings immediately * Call this after updating permissions to avoid race conditions */ function refreshConfig(): void { if (!isSandboxingEnabled()) return const settings = getSettings_DEPRECATED() const newConfig = convertToSandboxRuntimeConfig(settings) BaseSandboxManager.updateConfig(newConfig) } /** * Reset sandbox state and clear memoized values */ async function reset(): Promise { // Clean up settings subscription settingsSubscriptionCleanup?.() settingsSubscriptionCleanup = undefined worktreeMainRepoPath = undefined bareGitRepoScrubPaths.length = 0 // Clear memoized caches checkDependencies.cache.clear?.() isSupportedPlatform.cache.clear?.() initializationPromise = undefined // Reset the base sandbox manager return BaseSandboxManager.reset() } /** * Add a command to the excluded commands list (commands that should not be sandboxed) * This is a Claude CLI-specific function that updates local settings. */ export function addToExcludedCommands( command: string, permissionUpdates?: Array<{ type: string rules: Array<{ toolName: string; ruleContent?: string }> }>, ): string { const existingSettings = getSettingsForSource('localSettings') const existingExcludedCommands = existingSettings?.sandbox?.excludedCommands || [] // Determine the command pattern to add // If there are suggestions with Bash rules, extract the pattern (e.g., "npm run test" from "npm run test:*") // Otherwise use the exact command let commandPattern: string = command if (permissionUpdates) { const bashSuggestions = permissionUpdates.filter( update => update.type === 'addRules' && update.rules.some(rule => rule.toolName === BASH_TOOL_NAME), ) if (bashSuggestions.length > 0 && bashSuggestions[0]!.type === 'addRules') { const firstBashRule = bashSuggestions[0]!.rules.find( rule => rule.toolName === BASH_TOOL_NAME, ) if (firstBashRule?.ruleContent) { // Extract pattern from Bash(command) or Bash(command:*) format const prefix = permissionRuleExtractPrefix(firstBashRule.ruleContent) commandPattern = prefix || firstBashRule.ruleContent } } } // Add to excludedCommands if not already present if (!existingExcludedCommands.includes(commandPattern)) { updateSettingsForSource('localSettings', { sandbox: { ...existingSettings?.sandbox, excludedCommands: [...existingExcludedCommands, commandPattern], }, }) } return commandPattern } // ============================================================================ // Export interface and implementation // ============================================================================ export interface ISandboxManager { initialize(sandboxAskCallback?: SandboxAskCallback): Promise isSupportedPlatform(): boolean isPlatformInEnabledList(): boolean getSandboxUnavailableReason(): string | undefined isSandboxingEnabled(): boolean isSandboxEnabledInSettings(): boolean checkDependencies(): SandboxDependencyCheck isAutoAllowBashIfSandboxedEnabled(): boolean areUnsandboxedCommandsAllowed(): boolean isSandboxRequired(): boolean areSandboxSettingsLockedByPolicy(): boolean setSandboxSettings(options: { enabled?: boolean autoAllowBashIfSandboxed?: boolean allowUnsandboxedCommands?: boolean }): Promise getFsReadConfig(): FsReadRestrictionConfig getFsWriteConfig(): FsWriteRestrictionConfig getNetworkRestrictionConfig(): NetworkRestrictionConfig getAllowUnixSockets(): string[] | undefined getAllowLocalBinding(): boolean | undefined getIgnoreViolations(): IgnoreViolationsConfig | undefined getEnableWeakerNestedSandbox(): boolean | undefined getExcludedCommands(): string[] getProxyPort(): number | undefined getSocksProxyPort(): number | undefined getLinuxHttpSocketPath(): string | undefined getLinuxSocksSocketPath(): string | undefined waitForNetworkInitialization(): Promise wrapWithSandbox( command: string, binShell?: string, customConfig?: Partial, abortSignal?: AbortSignal, ): Promise cleanupAfterCommand(): void getSandboxViolationStore(): SandboxViolationStore annotateStderrWithSandboxFailures(command: string, stderr: string): string getLinuxGlobPatternWarnings(): string[] refreshConfig(): void reset(): Promise } /** * Claude CLI sandbox manager - wraps sandbox-runtime with Claude-specific features */ export const SandboxManager: ISandboxManager = { // Custom implementations initialize, isSandboxingEnabled, isSandboxEnabledInSettings: getSandboxEnabledSetting, isPlatformInEnabledList, getSandboxUnavailableReason, isAutoAllowBashIfSandboxedEnabled, areUnsandboxedCommandsAllowed, isSandboxRequired, areSandboxSettingsLockedByPolicy, setSandboxSettings, getExcludedCommands, wrapWithSandbox, refreshConfig, reset, checkDependencies, // Forward to base sandbox manager getFsReadConfig: BaseSandboxManager.getFsReadConfig, getFsWriteConfig: BaseSandboxManager.getFsWriteConfig, getNetworkRestrictionConfig: BaseSandboxManager.getNetworkRestrictionConfig, getIgnoreViolations: BaseSandboxManager.getIgnoreViolations, getLinuxGlobPatternWarnings, isSupportedPlatform, getAllowUnixSockets: BaseSandboxManager.getAllowUnixSockets, getAllowLocalBinding: BaseSandboxManager.getAllowLocalBinding, getEnableWeakerNestedSandbox: BaseSandboxManager.getEnableWeakerNestedSandbox, getProxyPort: BaseSandboxManager.getProxyPort, getSocksProxyPort: BaseSandboxManager.getSocksProxyPort, getLinuxHttpSocketPath: BaseSandboxManager.getLinuxHttpSocketPath, getLinuxSocksSocketPath: BaseSandboxManager.getLinuxSocksSocketPath, waitForNetworkInitialization: BaseSandboxManager.waitForNetworkInitialization, getSandboxViolationStore: BaseSandboxManager.getSandboxViolationStore, annotateStderrWithSandboxFailures: BaseSandboxManager.annotateStderrWithSandboxFailures, cleanupAfterCommand: (): void => { BaseSandboxManager.cleanupAfterCommand() scrubBareGitRepoFiles() }, } // ============================================================================ // Re-export types from sandbox-runtime // ============================================================================ export type { SandboxAskCallback, SandboxDependencyCheck, FsReadRestrictionConfig, FsWriteRestrictionConfig, NetworkRestrictionConfig, NetworkHostPattern, SandboxViolationEvent, SandboxRuntimeConfig, IgnoreViolationsConfig, } export { SandboxViolationStore, SandboxRuntimeConfigSchema }