Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
36 kB · 985 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986/** * 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<void> | undefinedlet 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<string | null> { 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<void> { 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<SandboxRuntimeConfig>, abortSignal?: AbortSignal,): Promise<string> { // 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<void> { // 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<void> { // 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<void> 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<void> 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<boolean> wrapWithSandbox( command: string, binShell?: string, customConfig?: Partial<SandboxRuntimeConfig>, abortSignal?: AbortSignal, ): Promise<string> cleanupAfterCommand(): void getSandboxViolationStore(): SandboxViolationStore annotateStderrWithSandboxFailures(command: string, stderr: string): string getLinuxGlobPatternWarnings(): string[] refreshConfig(): void reset(): Promise<void>}
/** * 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 }