source dump of claude code
at main 985 lines 36 kB view raw
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 7import 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' 18import { 19 SandboxManager as BaseSandboxManager, 20 SandboxRuntimeConfigSchema, 21 SandboxViolationStore, 22} from '@anthropic-ai/sandbox-runtime' 23import { rmSync, statSync } from 'fs' 24import { readFile } from 'fs/promises' 25import { memoize } from 'lodash-es' 26import { join, resolve, sep } from 'path' 27import { 28 getAdditionalDirectoriesForClaudeMd, 29 getCwdState, 30 getOriginalCwd, 31} from '../../bootstrap/state.js' 32import { logForDebugging } from '../debug.js' 33import { expandPath } from '../path.js' 34import { getPlatform, type Platform } from '../platform.js' 35import { settingsChangeDetector } from '../settings/changeDetector.js' 36import { SETTING_SOURCES, type SettingSource } from '../settings/constants.js' 37import { getManagedSettingsDropInDir } from '../settings/managedPath.js' 38import { 39 getInitialSettings, 40 getSettings_DEPRECATED, 41 getSettingsFilePathForSource, 42 getSettingsForSource, 43 getSettingsRootPathForSource, 44 updateSettingsForSource, 45} from '../settings/settings.js' 46import type { SettingsJson } from '../settings/types.js' 47 48// ============================================================================ 49// Settings Converter 50// ============================================================================ 51 52import { BASH_TOOL_NAME } from 'src/tools/BashTool/toolName.js' 53import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js' 54import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js' 55import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js' 56import { errorMessage } from '../errors.js' 57import { getClaudeTempDir } from '../permissions/filesystem.js' 58import type { PermissionRuleValue } from '../permissions/PermissionRule.js' 59import { ripgrepCommand } from '../ripgrep.js' 60 61// Local copies to avoid circular dependency 62// (permissions.ts imports SandboxManager, bashPermissions.ts imports permissions.ts) 63function 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 78function 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 */ 99export 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 */ 138export 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 */ 152export function shouldAllowManagedSandboxDomainsOnly(): boolean { 153 return ( 154 getSettingsForSource('policySettings')?.sandbox?.network 155 ?.allowManagedDomainsOnly === true 156 ) 157} 158 159function 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 */ 172export 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 387let initializationPromise: Promise<void> | undefined 388let 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. 393let 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. 397const 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 */ 404function 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 */ 422async 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 */ 451const checkDependencies = memoize((): SandboxDependencyCheck => { 452 const { rgPath, rgArgs } = ripgrepCommand() 453 return BaseSandboxManager.checkDependencies({ 454 command: rgPath, 455 args: rgArgs, 456 }) 457}) 458 459function 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 469function isAutoAllowBashIfSandboxedEnabled(): boolean { 470 const settings = getSettings_DEPRECATED() 471 return settings?.sandbox?.autoAllowBashIfSandboxed ?? true 472} 473 474function areUnsandboxedCommandsAllowed(): boolean { 475 const settings = getSettings_DEPRECATED() 476 return settings?.sandbox?.allowUnsandboxedCommands ?? true 477} 478 479function 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 */ 491const 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 */ 505function 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 */ 532function 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 */ 562function 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 */ 597function 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 */ 647function 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 */ 669async 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 */ 696function 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 */ 704async 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 */ 730async 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 */ 798function 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 */ 808async 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 */ 828export 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 880export 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 */ 927export 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 973export type { 974 SandboxAskCallback, 975 SandboxDependencyCheck, 976 FsReadRestrictionConfig, 977 FsWriteRestrictionConfig, 978 NetworkRestrictionConfig, 979 NetworkHostPattern, 980 SandboxViolationEvent, 981 SandboxRuntimeConfig, 982 IgnoreViolationsConfig, 983} 984 985export { SandboxViolationStore, SandboxRuntimeConfigSchema }