diff --git a/src/index.ts b/src/index.ts index b48ede7..cfefb74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,7 +28,10 @@ export type { } from './sandbox/sandbox-schemas.js' // Platform-specific utilities -export type { SandboxViolationEvent } from './sandbox/macos-sandbox-utils.js' +export type { + SandboxViolationEvent, + SandboxViolationType, +} from './sandbox/macos-sandbox-utils.js' // Utility functions export { getDefaultWritePaths } from './sandbox/sandbox-utils.js' diff --git a/src/sandbox/linux-sandbox-utils.ts b/src/sandbox/linux-sandbox-utils.ts index 15f8dff..aa5e86a 100644 --- a/src/sandbox/linux-sandbox-utils.ts +++ b/src/sandbox/linux-sandbox-utils.ts @@ -5,7 +5,7 @@ import * as fs from 'fs' import { spawn, spawnSync } from 'node:child_process' import type { ChildProcess } from 'node:child_process' import { tmpdir } from 'node:os' -import path, { join } from 'node:path' +import * as path from 'node:path' import { ripGrep } from '../utils/ripgrep.js' import { generateProxyEnvVars, @@ -36,11 +36,11 @@ export interface LinuxNetworkBridgeContext { export interface LinuxSandboxParams { command: string - needsNetworkRestriction: boolean httpSocketPath?: string socksSocketPath?: string httpProxyPort?: number socksProxyPort?: number + blockAllNetwork?: boolean readConfig?: FsReadRestrictionConfig writeConfig?: FsWriteRestrictionConfig enableWeakerNestedSandbox?: boolean @@ -128,7 +128,7 @@ async function linuxGetMandatoryDenyPaths( const normalizedDirName = normalizeCaseForComparison(dirName) const segments = absolutePath.split(path.sep) const dirIndex = segments.findIndex( - s => normalizeCaseForComparison(s) === normalizedDirName, + (s: string) => normalizeCaseForComparison(s) === normalizedDirName, ) if (dirIndex !== -1) { // For .git, we want hooks/ or config, not the whole .git dir @@ -257,8 +257,8 @@ export async function initializeLinuxNetworkBridge( socksProxyPort: number, ): Promise { const socketId = randomBytes(8).toString('hex') - const httpSocketPath = join(tmpdir(), `claude-http-${socketId}.sock`) - const socksSocketPath = join(tmpdir(), `claude-socks-${socketId}.sock`) + const httpSocketPath = path.join(tmpdir(), `claude-http-${socketId}.sock`) + const socksSocketPath = path.join(tmpdir(), `claude-socks-${socketId}.sock`) // Start HTTP bridge const httpSocatArgs = [ @@ -616,11 +616,11 @@ export async function wrapCommandWithSandboxLinux( ): Promise { const { command, - needsNetworkRestriction, httpSocketPath, socksSocketPath, httpProxyPort, socksProxyPort, + blockAllNetwork, readConfig, writeConfig, enableWeakerNestedSandbox, @@ -631,21 +631,6 @@ export async function wrapCommandWithSandboxLinux( abortSignal, } = params - // Determine if we have restrictions to apply - // Read: denyOnly pattern - empty array means no restrictions - // Write: allowOnly pattern - undefined means no restrictions, any config means restrictions - const hasReadRestrictions = readConfig && readConfig.denyOnly.length > 0 - const hasWriteRestrictions = writeConfig !== undefined - - // Check if we need any sandboxing - if ( - !needsNetworkRestriction && - !hasReadRestrictions && - !hasWriteRestrictions - ) { - return command - } - const bwrapArgs: string[] = [] let seccompFilterPath: string | undefined = undefined @@ -685,67 +670,69 @@ export async function wrapCommandWithSandboxLinux( } // ========== NETWORK RESTRICTIONS ========== - if (needsNetworkRestriction) { - // Always unshare network namespace to isolate network access - // This removes all network interfaces, effectively blocking all network - bwrapArgs.push('--unshare-net') - - // If proxy sockets are provided, bind them into the sandbox to allow - // filtered network access through the proxy. If not provided, network - // is completely blocked (empty allowedDomains = block all) - if (httpSocketPath && socksSocketPath) { - // Verify socket files still exist before trying to bind them - if (!fs.existsSync(httpSocketPath)) { - throw new Error( - `Linux HTTP bridge socket does not exist: ${httpSocketPath}. ` + - 'The bridge process may have died. Try reinitializing the sandbox.', - ) - } - if (!fs.existsSync(socksSocketPath)) { - throw new Error( - `Linux SOCKS bridge socket does not exist: ${socksSocketPath}. ` + - 'The bridge process may have died. Try reinitializing the sandbox.', - ) - } + // Network is always isolated. Either proxied through bridge sockets, or blocked entirely. + bwrapArgs.push('--unshare-net') + + if (!blockAllNetwork) { + // Verify socket files still exist before trying to bind them + if (!httpSocketPath || !fs.existsSync(httpSocketPath)) { + throw new Error( + `Linux HTTP bridge socket does not exist: ${httpSocketPath}. ` + + 'The bridge process may have died. Try reinitializing the sandbox.', + ) + } + if (!socksSocketPath || !fs.existsSync(socksSocketPath)) { + throw new Error( + `Linux SOCKS bridge socket does not exist: ${socksSocketPath}. ` + + 'The bridge process may have died. Try reinitializing the sandbox.', + ) + } - // Bind both sockets into the sandbox - bwrapArgs.push('--bind', httpSocketPath, httpSocketPath) - bwrapArgs.push('--bind', socksSocketPath, socksSocketPath) + // Bind both sockets into the sandbox + bwrapArgs.push('--bind', httpSocketPath, httpSocketPath) + bwrapArgs.push('--bind', socksSocketPath, socksSocketPath) - // Add proxy environment variables - // HTTP_PROXY points to the socat listener inside the sandbox (port 3128) - // which forwards to the Unix socket that bridges to the host's proxy server - const proxyEnv = generateProxyEnvVars( - 3128, // Internal HTTP listener port - 1080, // Internal SOCKS listener port + // Add proxy environment variables + // HTTP_PROXY points to the socat listener inside the sandbox (port 3128) + // which forwards to the Unix socket that bridges to the host's proxy server + const proxyEnv = generateProxyEnvVars( + 3128, // Internal HTTP listener port + 1080, // Internal SOCKS listener port + ) + bwrapArgs.push( + ...proxyEnv.flatMap((env: string) => { + const firstEq = env.indexOf('=') + const key = env.slice(0, firstEq) + const value = env.slice(firstEq + 1) + return ['--setenv', key, value] + }), + ) + + // Add host proxy port environment variables for debugging/transparency + // These show which host ports the Unix socket bridges connect to + if (httpProxyPort !== undefined) { + bwrapArgs.push( + '--setenv', + 'CLAUDE_CODE_HOST_HTTP_PROXY_PORT', + String(httpProxyPort), ) + } + if (socksProxyPort !== undefined) { bwrapArgs.push( - ...proxyEnv.flatMap((env: string) => { - const firstEq = env.indexOf('=') - const key = env.slice(0, firstEq) - const value = env.slice(firstEq + 1) - return ['--setenv', key, value] - }), + '--setenv', + 'CLAUDE_CODE_HOST_SOCKS_PROXY_PORT', + String(socksProxyPort), ) - - // Add host proxy port environment variables for debugging/transparency - // These show which host ports the Unix socket bridges connect to - if (httpProxyPort !== undefined) { - bwrapArgs.push( - '--setenv', - 'CLAUDE_CODE_HOST_HTTP_PROXY_PORT', - String(httpProxyPort), - ) - } - if (socksProxyPort !== undefined) { - bwrapArgs.push( - '--setenv', - 'CLAUDE_CODE_HOST_SOCKS_PROXY_PORT', - String(socksProxyPort), - ) + } + } else { + // Hide any bridge socket paths to prevent access via filesystem + // (Unix sockets are filesystem-based, not affected by --unshare-net) + // When blocking all network, hide the proxy sockets if they exist + for (const socketPath of [httpSocketPath, socksSocketPath]) { + if (socketPath && fs.existsSync(socketPath)) { + bwrapArgs.push('--ro-bind', '/dev/null', socketPath) } } - // If no sockets provided, network is completely blocked (--unshare-net without proxy) } // ========== FILESYSTEM RESTRICTIONS ========== @@ -787,9 +774,9 @@ export async function wrapCommandWithSandboxLinux( const shell = shellPathResult.stdout.trim() bwrapArgs.push('--', shell, '-c') - // If we have network restrictions, use the network bridge setup with apply-seccomp for seccomp + // If we have network proxy, use the network bridge setup with apply-seccomp for seccomp // Otherwise, just run the command directly with apply-seccomp if needed - if (needsNetworkRestriction && httpSocketPath && socksSocketPath) { + if (!blockAllNetwork && httpSocketPath && socksSocketPath) { // Pass seccomp filter to buildSandboxCommand for apply-seccomp application // This allows socat to start before seccomp is applied const sandboxCommand = buildSandboxCommand( @@ -827,9 +814,8 @@ export async function wrapCommandWithSandboxLinux( const wrappedCommand = shellquote.quote(['bwrap', ...bwrapArgs]) const restrictions = [] - if (needsNetworkRestriction) restrictions.push('network') - if (hasReadRestrictions || hasWriteRestrictions) - restrictions.push('filesystem') + restrictions.push(blockAllNetwork ? 'network(blocked)' : 'network(proxy)') + if (readConfig || writeConfig) restrictions.push('filesystem') if (seccompFilterPath) restrictions.push('seccomp(unix-block)') logForDebugging( diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 70bc373..225b180 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -19,12 +19,14 @@ import type { IgnoreViolationsConfig } from './sandbox-config.js' export interface MacOSSandboxParams { command: string + executionId: string needsNetworkRestriction: boolean httpProxyPort?: number socksProxyPort?: number allowUnixSockets?: string[] allowAllUnixSockets?: boolean allowLocalBinding?: boolean + blockAllNetwork?: boolean readConfig: FsReadRestrictionConfig | undefined writeConfig: FsWriteRestrictionConfig | undefined ignoreViolations?: IgnoreViolationsConfig | undefined @@ -60,13 +62,33 @@ export function macGetMandatoryDenyPatterns(): string[] { return [...new Set(denyPaths)] } +export type SandboxViolationType = 'filesystem' | 'network' | 'other' + export interface SandboxViolationEvent { line: string + type: SandboxViolationType + executionId?: string command?: string encodedCommand?: string timestamp: Date } +function getViolationType(line: string): SandboxViolationType { + // Network: explicit network ops or network-related daemons + if ( + line.includes('network') || + line.includes('configd') || + line.includes('mDNSResponder') + ) { + return 'network' + } + // Filesystem: macOS sandbox file operations + if (line.includes('file-read') || line.includes('file-write')) { + return 'filesystem' + } + return 'other' +} + export type SandboxViolationCallback = ( violation: SandboxViolationEvent, ) => void @@ -112,11 +134,12 @@ export function globToRegex(globPattern: string): string { /** * Generate a unique log tag for sandbox monitoring + * @param executionId - Unique ID for this execution * @param command - The command being executed (will be base64 encoded) */ -function generateLogTag(command: string): string { - const encodedCommand = encodeSandboxedCommand(command) - return `CMD64_${encodedCommand}_END_${sessionSuffix}` +function generateLogTag(executionId: string, command: string): string { + const encodedCmd = encodeSandboxedCommand(command) + return `EXECID_${executionId}_CMD64_${encodedCmd}_END_${sessionSuffix}` } /** @@ -353,6 +376,7 @@ function generateSandboxProfile({ allowUnixSockets, allowAllUnixSockets, allowLocalBinding, + blockAllNetwork, logTag, }: { readConfig: FsReadRestrictionConfig | undefined @@ -363,6 +387,7 @@ function generateSandboxProfile({ allowUnixSockets?: string[] allowAllUnixSockets?: boolean allowLocalBinding?: boolean + blockAllNetwork?: boolean logTag: string }): string { const profile: string[] = [ @@ -399,6 +424,7 @@ function generateSandboxProfile({ ' (global-name "com.apple.bsd.dirhelper")', ' (global-name "com.apple.securityd.xpc")', ' (global-name "com.apple.coreservices.launchservicesd")', + ' (global-name "com.apple.diagnosticd")', ')', '', '; POSIX IPC - shared memory', @@ -511,8 +537,15 @@ function generateSandboxProfile({ // Network rules profile.push('; Network') - if (!needsNetworkRestriction) { + + // When blockAllNetwork is true, explicitly deny ALL network operations + // This provides defense-in-depth beyond (deny default) and blocks localhost too + if (blockAllNetwork) { + profile.push(`(deny network* (with message "${logTag}"))`) + profile.push('') + } else if (!needsNetworkRestriction) { profile.push('(allow network*)') + profile.push('') } else { // Allow local binding if requested if (allowLocalBinding) { @@ -558,8 +591,8 @@ function generateSandboxProfile({ `(allow network-outbound (remote ip "localhost:${socksProxyPort}"))`, ) } + profile.push('') } - profile.push('') // Read rules profile.push('; File read') @@ -613,12 +646,14 @@ export function wrapCommandWithSandboxMacOS( ): string { const { command, + executionId, needsNetworkRestriction, httpProxyPort, socksProxyPort, allowUnixSockets, allowAllUnixSockets, allowLocalBinding, + blockAllNetwork, readConfig, writeConfig, binShell, @@ -639,7 +674,7 @@ export function wrapCommandWithSandboxMacOS( return command } - const logTag = generateLogTag(command) + const logTag = generateLogTag(executionId, command) const profile = generateSandboxProfile({ readConfig, @@ -650,10 +685,13 @@ export function wrapCommandWithSandboxMacOS( allowUnixSockets, allowAllUnixSockets, allowLocalBinding, + blockAllNetwork, logTag, }) - // Generate proxy environment variables using shared utility + // Generate proxy environment variables + // When blockAllNetwork is true, httpProxyPort/socksProxyPort are undefined, + // so generateProxyEnvVars returns just basic env vars (SANDBOX_RUNTIME, TMPDIR) const proxyEnv = `export ${generateProxyEnvVars(httpProxyPort, socksProxyPort).join(' ')} && ` // Use the user's shell (zsh, bash, etc.) to ensure aliases/snapshots work @@ -696,13 +734,17 @@ export function wrapCommandWithSandboxMacOS( /** * Start monitoring macOS system logs for sandbox violations * Look for sandbox-related kernel deny events ending in {logTag} + * + * @param skipFiltering - If true, don't filter any violations (needed for speculative execution) */ export function startMacOSSandboxLogMonitor( callback: SandboxViolationCallback, ignoreViolations?: IgnoreViolationsConfig, + skipFiltering?: boolean, ): () => void { // Pre-compile regex patterns for better performance - const cmdExtractRegex = /CMD64_(.+?)_END/ + // Log tag format: EXECID__CMD64__END_ + const tagExtractRegex = /EXECID_([^_]+)_CMD64_([^_]+)_END/ const sandboxExtractRegex = /Sandbox:\s+(.+)$/ // Pre-process ignore patterns for faster lookup @@ -712,7 +754,6 @@ export function startMacOSSandboxLogMonitor( : [] // Stream and filter kernel logs for all sandbox violations - // We can't filter by specific logTag since it's dynamic per command const logProcess = spawn('log', [ 'stream', '--predicate', @@ -722,74 +763,90 @@ export function startMacOSSandboxLogMonitor( ]) logProcess.stdout?.on('data', (data: Buffer) => { - const lines = data.toString().split('\n') + const isViolation = (line: string): boolean => + line.includes('Sandbox:') && line.includes('deny') + const isTag = (line: string): boolean => line.startsWith('EXECID_') + + // Filter to relevant lines, then pair violations with their tags + const relevantLines = data + .toString() + .split('\n') + .filter(line => isViolation(line) || isTag(line)) + + relevantLines + .flatMap((line, i) => + isViolation(line) + ? [{ violationLine: line, tagLine: relevantLines[i + 1] }] + : [], + ) + .map(({ violationLine, tagLine }) => { + const sandboxMatch = violationLine.match(sandboxExtractRegex) + if (!sandboxMatch?.[1]) return null + + const violationDetails = sandboxMatch[1] + + // Extract execution ID and command from tag line + let executionId: string | undefined + let command: string | undefined + if (tagLine?.startsWith('EXECID_')) { + const tagMatch = tagLine.match(tagExtractRegex) + if (tagMatch) { + executionId = tagMatch[1] + try { + if (tagMatch[2]) { + command = decodeSandboxedCommand(tagMatch[2]) + } + } catch { + // Failed to decode, continue without command + } + } + } - // Get violation and command lines - const violationLine = lines.find( - line => line.includes('Sandbox:') && line.includes('deny'), - ) - const commandLine = lines.find(line => line.startsWith('CMD64_')) - - if (!violationLine) return - - // Extract violation details - const sandboxMatch = violationLine.match(sandboxExtractRegex) - if (!sandboxMatch?.[1]) return - - const violationDetails = sandboxMatch[1] - - // Try to get command - let command: string | undefined - let encodedCommand: string | undefined - if (commandLine) { - const cmdMatch = commandLine.match(cmdExtractRegex) - encodedCommand = cmdMatch?.[1] - if (encodedCommand) { - try { - command = decodeSandboxedCommand(encodedCommand) - } catch { - // Failed to decode, continue without command + return { violationDetails, executionId, command } + }) + .filter((v): v is NonNullable => v !== null) + .filter(({ violationDetails, command }) => { + // Skip all filtering for speculative execution - we need every violation + if (skipFiltering) return true + + // Filter out noisy system violations that don't indicate real problems + if ( + violationDetails.includes('mDNSResponder') || + violationDetails.includes('mach-lookup com.apple.analyticsd') + ) { + return false } - } - } - // Always filter out noisey violations - if ( - violationDetails.includes('mDNSResponder') || - violationDetails.includes('mach-lookup com.apple.diagnosticd') || - violationDetails.includes('mach-lookup com.apple.analyticsd') - ) { - return - } + if (!ignoreViolations) return true - // Check if we should ignore this violation - if (ignoreViolations && command) { - // Check wildcard patterns first - if (wildcardPaths.length > 0) { - const shouldIgnore = wildcardPaths.some(path => - violationDetails.includes(path), - ) - if (shouldIgnore) return - } + // Check wildcard patterns + if (wildcardPaths.some(path => violationDetails.includes(path))) { + return false + } - // Check command-specific patterns - for (const [pattern, paths] of commandPatterns) { - if (command.includes(pattern)) { - const shouldIgnore = paths.some(path => - violationDetails.includes(path), - ) - if (shouldIgnore) return + // Check command-specific patterns + if (command) { + for (const [pattern, paths] of commandPatterns) { + if ( + command.includes(pattern) && + paths.some(path => violationDetails.includes(path)) + ) { + return false + } + } } - } - } - // Not ignored - report the violation - callback({ - line: violationDetails, - command, - encodedCommand, - timestamp: new Date(), // We could parse the timestamp from the log but this feels more reliable - }) + return true + }) + .forEach(({ violationDetails, executionId, command }) => { + callback({ + line: violationDetails, + type: getViolationType(violationDetails), + executionId, + command, + timestamp: new Date(), + }) + }) }) logProcess.stderr?.on('data', (data: Buffer) => { diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 220fd3a..804fa9a 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -65,6 +65,12 @@ export const NetworkConfigSchema = z.object({ deniedDomains: z .array(domainPatternSchema) .describe('List of denied domains'), + blockAllNetwork: z + .boolean() + .optional() + .describe( + 'Block all network access entirely (no proxy, no localhost). Takes precedence over other network settings.', + ), allowUnixSockets: z .array(z.string()) .optional() diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index d70887a..702a3a2 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -29,7 +29,7 @@ import { } from './sandbox-utils.js' import { hasRipgrepSync } from '../utils/ripgrep.js' import { SandboxViolationStore } from './sandbox-violation-store.js' -import { EOL } from 'node:os' +import { randomUUID } from 'node:crypto' interface HostNetworkManagerContext { httpProxyPort: number @@ -196,6 +196,7 @@ async function initialize( runtimeConfig: SandboxRuntimeConfig, sandboxAskCallback?: SandboxAskCallback, enableLogMonitor = false, + skipViolationFiltering = false, ): Promise { // Return if already initializing if (initializationPromise) { @@ -227,6 +228,7 @@ async function initialize( logMonitorShutdown = startMacOSSandboxLogMonitor( sandboxViolationStore.addViolation.bind(sandboxViolationStore), config.ignoreViolations, + skipViolationFiltering, ) logForDebugging('Started macOS sandbox log monitor') } @@ -481,6 +483,7 @@ async function wrapWithSandbox( command: string, binShell?: string, customConfig?: Partial, + executionId?: string, abortSignal?: AbortSignal, ): Promise { const platform = getPlatform() @@ -500,48 +503,34 @@ async function wrapWithSandbox( customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], } - // Check if network config is specified - this determines if we need network restrictions - // Network restriction is needed when: - // 1. customConfig has network.allowedDomains defined (even if empty array = block all) - // 2. OR config has network.allowedDomains defined (even if empty array = block all) - // An empty allowedDomains array means "no domains allowed" = block all network access - const hasNetworkConfig = - customConfig?.network?.allowedDomains !== undefined || - config?.network?.allowedDomains !== undefined - - // Get the actual allowed domains list for proxy filtering - const allowedDomains = - customConfig?.network?.allowedDomains ?? - config?.network.allowedDomains ?? - [] - - // Network RESTRICTION is needed whenever network config is specified - // This includes empty allowedDomains which means "block all network" - const needsNetworkRestriction = hasNetworkConfig - - // Network PROXY is only needed when there are domains to filter - // If allowedDomains is empty, we block all network and don't need the proxy - const needsNetworkProxy = allowedDomains.length > 0 - - // Wait for network initialization only if proxy is actually needed - if (needsNetworkProxy) { + // blockAllNetwork: true means block all network (no proxy, no localhost) + const blockAllNetwork = + customConfig?.network?.blockAllNetwork ?? + config?.network.blockAllNetwork ?? + false + + if (!blockAllNetwork) { await waitForNetworkInitialization() } + // Generate execution ID if not provided + const execId = executionId ?? randomUUID() + switch (platform) { case 'macos': // macOS sandbox profile supports glob patterns directly, no ripgrep needed return wrapCommandWithSandboxMacOS({ command, - needsNetworkRestriction, - // Only pass proxy ports if proxy is running (when there are domains to filter) - httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined, - socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined, + executionId: execId, + needsNetworkRestriction: !blockAllNetwork, + httpProxyPort: blockAllNetwork ? undefined : getProxyPort(), + socksProxyPort: blockAllNetwork ? undefined : getSocksProxyPort(), readConfig, writeConfig, - allowUnixSockets: getAllowUnixSockets(), - allowAllUnixSockets: getAllowAllUnixSockets(), - allowLocalBinding: getAllowLocalBinding(), + allowUnixSockets: blockAllNetwork ? undefined : getAllowUnixSockets(), + allowAllUnixSockets: blockAllNetwork ? false : getAllowAllUnixSockets(), + allowLocalBinding: blockAllNetwork ? false : getAllowLocalBinding(), + blockAllNetwork, ignoreViolations: getIgnoreViolations(), binShell, }) @@ -549,24 +538,21 @@ async function wrapWithSandbox( case 'linux': return wrapCommandWithSandboxLinux({ command, - needsNetworkRestriction, - // Only pass socket paths if proxy is running (when there are domains to filter) - httpSocketPath: needsNetworkProxy - ? getLinuxHttpSocketPath() - : undefined, - socksSocketPath: needsNetworkProxy - ? getLinuxSocksSocketPath() - : undefined, - httpProxyPort: needsNetworkProxy - ? managerContext?.httpProxyPort - : undefined, - socksProxyPort: needsNetworkProxy - ? managerContext?.socksProxyPort - : undefined, + httpSocketPath: blockAllNetwork ? undefined : getLinuxHttpSocketPath(), + socksSocketPath: blockAllNetwork + ? undefined + : getLinuxSocksSocketPath(), + httpProxyPort: blockAllNetwork + ? undefined + : managerContext?.httpProxyPort, + socksProxyPort: blockAllNetwork + ? undefined + : managerContext?.socksProxyPort, + blockAllNetwork, readConfig, writeConfig, enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(), - allowAllUnixSockets: getAllowAllUnixSockets(), + allowAllUnixSockets: blockAllNetwork ? false : getAllowAllUnixSockets(), binShell, ripgrepConfig: getRipgrepConfig(), mandatoryDenySearchDepth: getMandatoryDenySearchDepth(), @@ -765,29 +751,6 @@ function getSandboxViolationStore() { return sandboxViolationStore } -function annotateStderrWithSandboxFailures( - command: string, - stderr: string, -): string { - if (!config) { - return stderr - } - - const violations = sandboxViolationStore.getViolationsForCommand(command) - if (violations.length === 0) { - return stderr - } - - let annotated = stderr - annotated += EOL + '' + EOL - for (const violation of violations) { - annotated += violation.line + EOL - } - annotated += '' - - return annotated -} - /** * Returns glob patterns from Edit/Read permission rules that are not * fully supported on Linux. Returns empty array on macOS or when @@ -836,6 +799,7 @@ export interface ISandboxManager { runtimeConfig: SandboxRuntimeConfig, sandboxAskCallback?: SandboxAskCallback, enableLogMonitor?: boolean, + skipViolationFiltering?: boolean, ): Promise isSupportedPlatform(platform: Platform): boolean isSandboxingEnabled(): boolean @@ -859,10 +823,10 @@ export interface ISandboxManager { command: string, binShell?: string, customConfig?: Partial, + executionId?: string, abortSignal?: AbortSignal, ): Promise getSandboxViolationStore(): SandboxViolationStore - annotateStderrWithSandboxFailures(command: string, stderr: string): string getLinuxGlobPatternWarnings(): string[] getConfig(): SandboxRuntimeConfig | undefined updateConfig(newConfig: SandboxRuntimeConfig): void @@ -897,7 +861,6 @@ export const SandboxManager: ISandboxManager = { wrapWithSandbox, reset, getSandboxViolationStore, - annotateStderrWithSandboxFailures, getLinuxGlobPatternWarnings, getConfig, updateConfig, diff --git a/src/sandbox/sandbox-violation-store.ts b/src/sandbox/sandbox-violation-store.ts index 9d923cc..f66e444 100644 --- a/src/sandbox/sandbox-violation-store.ts +++ b/src/sandbox/sandbox-violation-store.ts @@ -7,9 +7,13 @@ import { encodeSandboxedCommand } from './sandbox-utils.js' export class SandboxViolationStore { private violations: SandboxViolationEvent[] = [] private totalCount = 0 - private readonly maxSize = 100 + private readonly maxSize = 500 private listeners: Set<(violations: SandboxViolationEvent[]) => void> = new Set() + private executionListeners: Map< + string, + Set<(violation: SandboxViolationEvent) => void> + > = new Map() addViolation(violation: SandboxViolationEvent): void { this.violations.push(violation) @@ -18,6 +22,14 @@ export class SandboxViolationStore { this.violations = this.violations.slice(-this.maxSize) } this.notifyListeners() + + // Notify execution-specific listeners + if (violation.executionId) { + const listeners = this.executionListeners.get(violation.executionId) + if (listeners) { + listeners.forEach(listener => listener(violation)) + } + } } getViolations(limit?: number): SandboxViolationEvent[] { @@ -40,6 +52,10 @@ export class SandboxViolationStore { return this.violations.filter(v => v.encodedCommand === commandBase64) } + getViolationsForExecution(executionId: string): SandboxViolationEvent[] { + return this.violations.filter(v => v.executionId === executionId) + } + clear(): void { this.violations = [] // Don't reset totalCount when clearing @@ -56,6 +72,25 @@ export class SandboxViolationStore { } } + subscribeToExecution( + executionId: string, + listener: (violation: SandboxViolationEvent) => void, + ): () => void { + if (!this.executionListeners.has(executionId)) { + this.executionListeners.set(executionId, new Set()) + } + this.executionListeners.get(executionId)!.add(listener) + return () => { + const listeners = this.executionListeners.get(executionId) + if (listeners) { + listeners.delete(listener) + if (listeners.size === 0) { + this.executionListeners.delete(executionId) + } + } + } + } + private notifyListeners(): void { // Always notify with all violations so listeners can track the full count const violations = this.getViolations() diff --git a/test/sandbox/macos-seatbelt.test.ts b/test/sandbox/macos-seatbelt.test.ts index 405d099..447b2a4 100644 --- a/test/sandbox/macos-seatbelt.test.ts +++ b/test/sandbox/macos-seatbelt.test.ts @@ -735,3 +735,80 @@ describe('macOS Seatbelt Write Bypass Prevention', () => { }) }) }) + +describe('macOS Seatbelt blockAllNetwork', () => { + it('should block all network access including localhost when blockAllNetwork is true', () => { + if (skipIfNotMacOS()) { + return + } + + // Use blockAllNetwork to completely block all network + const wrappedCommand = wrapCommandWithSandboxMacOS({ + command: + 'curl -s --connect-timeout 2 http://localhost:80 || curl -s --connect-timeout 2 http://127.0.0.1:80', + executionId: 'test-block-all-network', + needsNetworkRestriction: true, + blockAllNetwork: true, + readConfig: undefined, + writeConfig: undefined, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // The network access should be blocked by sandbox + // curl will fail with "Operation not permitted" from the sandbox + const output = (result.stderr || '').toLowerCase() + expect( + output.includes('operation not permitted') || + output.includes("couldn't connect") || + result.status !== 0, + ).toBe(true) + }) + + it('should block outbound DNS resolution when blockAllNetwork is true', () => { + if (skipIfNotMacOS()) { + return + } + + const wrappedCommand = wrapCommandWithSandboxMacOS({ + command: 'host example.com', + executionId: 'test-block-dns', + needsNetworkRestriction: true, + blockAllNetwork: true, + readConfig: undefined, + writeConfig: undefined, + }) + + const result = spawnSync(wrappedCommand, { + shell: true, + encoding: 'utf8', + timeout: 10000, + }) + + // DNS resolution should fail + expect(result.status).not.toBe(0) + }) + + it('should generate sandbox profile with explicit network deny rule', () => { + if (skipIfNotMacOS()) { + return + } + + // Generate a command and check that the sandbox profile contains the deny network* rule + const wrappedCommand = wrapCommandWithSandboxMacOS({ + command: 'echo test', + executionId: 'test-profile-check', + needsNetworkRestriction: true, + blockAllNetwork: true, + readConfig: undefined, + writeConfig: undefined, + }) + + // The wrapped command should contain the deny network* rule in the profile + expect(wrappedCommand).toContain('deny network*') + }) +})