Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export interface LinuxSandboxParams {
allowAllUnixSockets?: boolean
binShell?: string
ripgrepConfig?: { command: string; args?: string[] }
env?: Record<string, string>
preCommand?: string
skipGitConfigProtection?: boolean
}

// Track generated seccomp filters for cleanup on process exit
Expand Down Expand Up @@ -280,6 +283,7 @@ function buildSandboxCommand(
userCommand: string,
seccompFilterPath: string | undefined,
shell?: string,
preCommand?: string,
): string {
// Default to bash for backward compatibility
const shellPath = shell || 'bash'
Expand All @@ -289,12 +293,17 @@ function buildSandboxCommand(
'trap "kill %1 %2 2>/dev/null; exit" EXIT',
]

// If preCommand is provided, run it after socat setup but before user command
// This is useful for initialization tasks like setting up certificates
const preCommandScript = preCommand ? [preCommand] : []

// If seccomp filter is provided, use apply-seccomp to apply it
if (seccompFilterPath) {
// apply-seccomp approach:
// 1. Outer bwrap/bash: starts socat processes (can use Unix sockets)
// 2. apply-seccomp: applies seccomp filter and execs user command
// 3. User command runs with seccomp active (Unix sockets blocked)
// 2. preCommand: runs initialization tasks (if provided)
// 3. apply-seccomp: applies seccomp filter and execs user command
// 4. User command runs with seccomp active (Unix sockets blocked)
//
// apply-seccomp is a simple C program that:
// - Sets PR_SET_NO_NEW_PRIVS
Expand All @@ -318,12 +327,17 @@ function buildSandboxCommand(
userCommand,
])

const innerScript = [...socatCommands, applySeccompCmd].join('\n')
const innerScript = [
...socatCommands,
...preCommandScript,
applySeccompCmd,
].join('\n')
return `${shellPath} -c ${shellquote.quote([innerScript])}`
} else {
// No seccomp filter - run user command directly
const innerScript = [
...socatCommands,
...preCommandScript,
`eval ${shellquote.quote([userCommand])}`,
].join('\n')

Expand All @@ -338,6 +352,7 @@ async function generateFilesystemArgs(
readConfig: FsReadRestrictionConfig | undefined,
writeConfig: FsWriteRestrictionConfig | undefined,
ripgrepConfig: { command: string; args?: string[] } = { command: 'rg' },
skipGitConfigProtection = false,
): Promise<string[]> {
const args: string[] = []
// fs already imported
Expand Down Expand Up @@ -378,7 +393,10 @@ async function generateFilesystemArgs(
// Deny writes within allowed paths (user-specified + mandatory denies)
const denyPaths = [
...(writeConfig.denyWithinAllow || []),
...(await getMandatoryDenyWithinAllow(ripgrepConfig)),
...(await getMandatoryDenyWithinAllow(
ripgrepConfig,
skipGitConfigProtection,
)),
]

for (const pathPattern of denyPaths) {
Expand Down Expand Up @@ -511,6 +529,9 @@ export async function wrapCommandWithSandboxLinux(
allowAllUnixSockets,
binShell,
ripgrepConfig = { command: 'rg' },
env: customEnv,
preCommand,
skipGitConfigProtection = false,
} = params

// Determine if we have restrictions to apply
Expand Down Expand Up @@ -601,6 +622,7 @@ export async function wrapCommandWithSandboxLinux(
const proxyEnv = generateProxyEnvVars(
3128, // Internal HTTP listener port
1080, // Internal SOCKS listener port
customEnv, // Custom environment variables from config
)
bwrapArgs.push(
...proxyEnv.flatMap((env: string) => {
Expand All @@ -627,13 +649,20 @@ export async function wrapCommandWithSandboxLinux(
String(socksProxyPort),
)
}
} else if (customEnv) {
// No network restrictions, but custom env vars are provided
// Add them directly without proxy env vars
for (const [key, value] of Object.entries(customEnv)) {
bwrapArgs.push('--setenv', key, value)
}
}

// ========== FILESYSTEM RESTRICTIONS ==========
const fsArgs = await generateFilesystemArgs(
readConfig,
writeConfig,
ripgrepConfig,
skipGitConfigProtection,
)
bwrapArgs.push(...fsArgs)

Expand Down Expand Up @@ -677,6 +706,7 @@ export async function wrapCommandWithSandboxLinux(
command,
seccompFilterPath,
shell,
preCommand,
)
bwrapArgs.push(sandboxCommand)
} else if (seccompFilterPath) {
Expand All @@ -690,16 +720,21 @@ export async function wrapCommandWithSandboxLinux(
)
}

// If preCommand is provided, prepend it to the user command
const commandWithPre = preCommand ? `${preCommand}\n${command}` : command
const applySeccompCmd = shellquote.quote([
applySeccompBinary,
seccompFilterPath,
shell,
'-c',
command,
commandWithPre,
])
bwrapArgs.push(applySeccompCmd)
} else {
bwrapArgs.push(command)
// No network restrictions and no seccomp - run command directly
// If preCommand is provided, prepend it to the user command
const commandWithPre = preCommand ? `${preCommand}\n${command}` : command
bwrapArgs.push(commandWithPre)
}

// Build the outer bwrap command
Expand Down
21 changes: 21 additions & 0 deletions src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,27 @@ export const SandboxRuntimeConfigSchema = z.object({
ripgrep: RipgrepConfigSchema.optional().describe(
'Custom ripgrep configuration (default: { command: "rg" })',
),
env: z
.record(z.string(), z.string())
.optional()
.describe('Custom environment variables to set inside the sandbox'),
preCommand: z
.string()
.optional()
.describe(
'Shell command to run inside the sandbox before the main command. ' +
'Runs after network bridges are established but before the user command. ' +
'Use for initialization tasks.',
),
skipGitConfigProtection: z
.boolean()
.optional()
.describe(
'Skip the mandatory protection that blocks writes to .git/config and .git/hooks. ' +
'WARNING: Only enable this when using an external security proxy ' +
'that already provides protection against git config exploits (core.fsmonitor, etc.). ' +
'Without this protection, malicious code could achieve arbitrary code execution via git.',
),
})

// Export inferred types
Expand Down
29 changes: 27 additions & 2 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,18 @@ function getRipgrepConfig(): { command: string; args?: string[] } {
return config?.ripgrep ?? { command: 'rg' }
}

function getEnv(): Record<string, string> | undefined {
return config?.env
}

function getPreCommand(): string | undefined {
return config?.preCommand
}

function getSkipGitConfigProtection(): boolean {
return config?.skipGitConfigProtection ?? false
}

function getProxyPort(): number | undefined {
return managerContext?.httpProxyPort
}
Expand Down Expand Up @@ -495,13 +507,19 @@ async function wrapWithSandbox(
customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [],
}

// Check if network proxy is needed based on allowed domains
// Check if network proxy is needed based on allowed domains or external proxy config
// Unix sockets are local IPC and don't require the network proxy
const allowedDomains =
customConfig?.network?.allowedDomains ??
config?.network.allowedDomains ??
[]
const needsNetworkProxy = allowedDomains.length > 0
// Network sandboxing is needed if:
// 1. There are allowed domains (sandbox-runtime enforces allowlist), OR
// 2. External proxy ports are configured (external proxy enforces allowlist)
const hasExternalProxy =
config?.network.httpProxyPort !== undefined ||
config?.network.socksProxyPort !== undefined
const needsNetworkProxy = allowedDomains.length > 0 || hasExternalProxy

// Wait for network initialization only if proxy is actually needed
if (needsNetworkProxy) {
Expand Down Expand Up @@ -539,6 +557,9 @@ async function wrapWithSandbox(
allowAllUnixSockets: getAllowAllUnixSockets(),
binShell,
ripgrepConfig: getRipgrepConfig(),
env: getEnv(),
preCommand: getPreCommand(),
skipGitConfigProtection: getSkipGitConfigProtection(),
})

default:
Expand Down Expand Up @@ -818,6 +839,8 @@ export interface ISandboxManager {
getAllowLocalBinding(): boolean | undefined
getIgnoreViolations(): Record<string, string[]> | undefined
getEnableWeakerNestedSandbox(): boolean | undefined
getEnv(): Record<string, string> | undefined
getPreCommand(): string | undefined
getProxyPort(): number | undefined
getSocksProxyPort(): number | undefined
getLinuxHttpSocketPath(): string | undefined
Expand Down Expand Up @@ -856,6 +879,8 @@ export const SandboxManager: ISandboxManager = {
getAllowLocalBinding,
getIgnoreViolations,
getEnableWeakerNestedSandbox,
getEnv,
getPreCommand,
getProxyPort,
getSocksProxyPort,
getLinuxHttpSocketPath,
Expand Down
106 changes: 62 additions & 44 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,11 @@ export function getDefaultWritePaths(): string[] {
* This uses ripgrep to scan the filesystem for dangerous files and directories
* Returns absolute paths that must be blocked from writes
* @param ripgrepConfig Ripgrep configuration (command and optional args)
* @param skipGitConfigProtection If true, skip blocking .git/config and .git/hooks
*/
export async function getMandatoryDenyWithinAllow(
ripgrepConfig: { command: string; args?: string[] } = { command: 'rg' },
skipGitConfigProtection = false,
): Promise<string[]> {
const denyPaths: string[] = []
const cwd = process.cwd()
Expand Down Expand Up @@ -271,52 +273,55 @@ export async function getMandatoryDenyWithinAllow(

// Special handling for dangerous .git paths
// We block specific paths within .git that can be used for code execution
const dangerousGitPaths = [
'.git/hooks', // Block all hook files to prevent code execution via git hooks
'.git/config', // Block config file to prevent dangerous config options like core.fsmonitor
]

for (const gitPath of dangerousGitPaths) {
// Add the path in the current working directory
const absoluteGitPath = path.resolve(cwd, gitPath)
denyPaths.push(absoluteGitPath)

// Also find .git directories in subdirectories and block their hooks/config
// This handles nested repositories (case-insensitive)
try {
// Find all .git directories by looking for .git/HEAD files (case-insensitive)
const gitHeadFiles = await ripGrep(
[
'--files',
'--hidden',
'--iglob',
'**/.git/HEAD',
'-g',
'!**/node_modules/**',
],
cwd,
abortController.signal,
ripgrepConfig,
)
// This can be skipped when using an external security proxy that handles these exploits
if (!skipGitConfigProtection) {
const dangerousGitPaths = [
'.git/hooks', // Block all hook files to prevent code execution via git hooks
'.git/config', // Block config file to prevent dangerous config options like core.fsmonitor
]

for (const gitPath of dangerousGitPaths) {
// Add the path in the current working directory
const absoluteGitPath = path.resolve(cwd, gitPath)
denyPaths.push(absoluteGitPath)

// Also find .git directories in subdirectories and block their hooks/config
// This handles nested repositories (case-insensitive)
try {
// Find all .git directories by looking for .git/HEAD files (case-insensitive)
const gitHeadFiles = await ripGrep(
[
'--files',
'--hidden',
'--iglob',
'**/.git/HEAD',
'-g',
'!**/node_modules/**',
],
cwd,
abortController.signal,
ripgrepConfig,
)

for (const gitHeadFile of gitHeadFiles) {
// Get the .git directory path
const gitDir = path.dirname(gitHeadFile)

// Add the dangerous path within this .git directory
if (gitPath === '.git/hooks') {
const hooksPath = path.join(gitDir, 'hooks')
denyPaths.push(hooksPath)
} else if (gitPath === '.git/config') {
const configPath = path.join(gitDir, 'config')
denyPaths.push(configPath)
for (const gitHeadFile of gitHeadFiles) {
// Get the .git directory path
const gitDir = path.dirname(gitHeadFile)

// Add the dangerous path within this .git directory
if (gitPath === '.git/hooks') {
const hooksPath = path.join(gitDir, 'hooks')
denyPaths.push(hooksPath)
} else if (gitPath === '.git/config') {
const configPath = path.join(gitDir, 'config')
denyPaths.push(configPath)
}
}
} catch (error) {
// If ripgrep fails, we cannot safely determine all .git repositories
throw new Error(
`Failed to scan for .git directories: ${error instanceof Error ? error.message : String(error)}`,
)
}
} catch (error) {
// If ripgrep fails, we cannot safely determine all .git repositories
throw new Error(
`Failed to scan for .git directories: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

Expand All @@ -330,11 +335,17 @@ export async function getMandatoryDenyWithinAllow(
export function generateProxyEnvVars(
httpProxyPort?: number,
socksProxyPort?: number,
customEnv?: Record<string, string>,
): string[] {
const envVars: string[] = [`SANDBOX_RUNTIME=1`, `TMPDIR=/tmp/claude`]

// If no proxy ports provided, return minimal env vars
// If no proxy ports provided, return minimal env vars (plus custom env)
if (!httpProxyPort && !socksProxyPort) {
if (customEnv) {
for (const [key, value] of Object.entries(customEnv)) {
envVars.push(`${key}=${value}`)
}
}
return envVars
}

Expand Down Expand Up @@ -422,6 +433,13 @@ export function generateProxyEnvVars(
// Most HTTP clients do not support SOCKS URLs in these variables and will fail, and we want
// to avoid overriding the client otherwise respecting the ALL_PROXY env var which points to SOCKS.

// Add custom environment variables (these can override the defaults above)
if (customEnv) {
for (const [key, value] of Object.entries(customEnv)) {
envVars.push(`${key}=${value}`)
}
}

return envVars
}

Expand Down
Loading
Loading