986 lines
35 KiB
TypeScript
986 lines
35 KiB
TypeScript
/**
|
|
* 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> | 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<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 }
|