/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import fs from 'node:fs'; import { join, dirname, normalize } from 'node:path'; import os from 'node:os'; import { type SandboxManager, type GlobalSandboxOptions, type SandboxRequest, type SandboxedCommand, type SandboxPermissions, GOVERNANCE_FILES, getSecretFileFindArgs, sanitizePaths, type ParsedSandboxDenial, } from '../../services/sandboxManager.js'; import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { spawnAsync } from '../../utils/shell-utils.js'; import { isStrictlyApproved, verifySandboxOverrides, getCommandName, } from '../utils/commandUtils.js'; import { tryRealpath, resolveGitWorktreePaths, isErrnoException, } from '../utils/fsUtils.js'; import { isKnownSafeCommand, isDangerousCommand, } from '../utils/commandSafety.js'; import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js'; import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js'; let cachedBpfPath: string | undefined; function getSeccompBpfPath(): string { if (cachedBpfPath) return cachedBpfPath; const arch = os.arch(); let AUDIT_ARCH: number; let SYS_ptrace: number; if (arch === 'x64') { AUDIT_ARCH = 0xc000003e; // AUDIT_ARCH_X86_64 SYS_ptrace = 101; } else if (arch === 'arm64') { AUDIT_ARCH = 0xc00000b7; // AUDIT_ARCH_AARCH64 SYS_ptrace = 117; } else if (arch === 'arm') { AUDIT_ARCH = 0x40000028; // AUDIT_ARCH_ARM SYS_ptrace = 26; } else if (arch === 'ia32') { AUDIT_ARCH = 0x40000003; // AUDIT_ARCH_I386 SYS_ptrace = 26; } else { throw new Error(`Unsupported architecture for seccomp filter: ${arch}`); } const EPERM = 1; const SECCOMP_RET_KILL_PROCESS = 0x80000000; const SECCOMP_RET_ERRNO = 0x00050000; const SECCOMP_RET_ALLOW = 0x7fff0000; const instructions = [ { code: 0x20, jt: 0, jf: 0, k: 4 }, // Load arch { code: 0x15, jt: 1, jf: 0, k: AUDIT_ARCH }, // Jump to kill if arch != native arch { code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_KILL_PROCESS }, // Kill { code: 0x20, jt: 0, jf: 0, k: 0 }, // Load nr { code: 0x15, jt: 0, jf: 1, k: SYS_ptrace }, // If ptrace, jump to ERRNO { code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ERRNO | EPERM }, // ERRNO { code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ALLOW }, // Allow ]; const buf = Buffer.alloc(8 * instructions.length); for (let i = 0; i < instructions.length; i++) { const inst = instructions[i]; const offset = i * 8; buf.writeUInt16LE(inst.code, offset); buf.writeUInt8(inst.jt, offset + 2); buf.writeUInt8(inst.jf, offset + 3); buf.writeUInt32LE(inst.k, offset + 4); } const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-')); const bpfPath = join(tempDir, 'seccomp.bpf'); fs.writeFileSync(bpfPath, buf); cachedBpfPath = bpfPath; // Cleanup on exit process.on('exit', () => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // Ignore errors } }); return bpfPath; } /** * Ensures a file or directory exists. */ function touch(filePath: string, isDirectory: boolean) { try { // If it exists (even as a broken symlink), do nothing if (fs.lstatSync(filePath)) return; } catch { // Ignore ENOENT } if (isDirectory) { fs.mkdirSync(filePath, { recursive: true }); } else { fs.mkdirSync(dirname(filePath), { recursive: true }); fs.closeSync(fs.openSync(filePath, 'a')); } } /** * A SandboxManager implementation for Linux that uses Bubblewrap (bwrap). */ export class LinuxSandboxManager implements SandboxManager { private static maskFilePath: string | undefined; constructor(private readonly options: GlobalSandboxOptions) {} isKnownSafeCommand(args: string[]): boolean { return isKnownSafeCommand(args); } isDangerousCommand(args: string[]): boolean { return isDangerousCommand(args); } parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { return parsePosixSandboxDenials(result); } private getMaskFilePath(): string { if ( LinuxSandboxManager.maskFilePath && fs.existsSync(LinuxSandboxManager.maskFilePath) ) { return LinuxSandboxManager.maskFilePath; } const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-')); const maskPath = join(tempDir, 'mask'); fs.writeFileSync(maskPath, ''); fs.chmodSync(maskPath, 0); LinuxSandboxManager.maskFilePath = maskPath; // Cleanup on exit process.on('exit', () => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // Ignore errors } }); return maskPath; } async prepareCommand(req: SandboxRequest): Promise { const isReadonlyMode = this.options.modeConfig?.readonly ?? true; const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; verifySandboxOverrides(allowOverrides, req.policy); let command = req.command; let args = req.args; // Translate virtual commands for sandboxed file system access if (command === '__read') { command = 'cat'; } else if (command === '__write') { command = 'sh'; args = ['-c', 'cat > "$1"', '_', ...args]; } const commandName = await getCommandName({ ...req, command, args }); const isApproved = allowOverrides ? await isStrictlyApproved( { ...req, command, args }, this.options.modeConfig?.approvedTools, ) : false; const workspaceWrite = !isReadonlyMode || isApproved; const networkAccess = this.options.modeConfig?.network || req.policy?.networkAccess || false; const persistentPermissions = allowOverrides ? this.options.policyManager?.getCommandPermissions(commandName) : undefined; const mergedAdditional: SandboxPermissions = { fileSystem: { read: [ ...(persistentPermissions?.fileSystem?.read ?? []), ...(req.policy?.additionalPermissions?.fileSystem?.read ?? []), ], write: [ ...(persistentPermissions?.fileSystem?.write ?? []), ...(req.policy?.additionalPermissions?.fileSystem?.write ?? []), ], }, network: networkAccess || persistentPermissions?.network || req.policy?.additionalPermissions?.network || false, }; const { command: finalCommand, args: finalArgs } = handleReadWriteCommands( req, mergedAdditional, this.options.workspace, req.policy?.allowedPaths, ); const sanitizationConfig = getSecureSanitizationConfig( req.policy?.sanitizationConfig, ); const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); const bwrapArgs: string[] = [ '--unshare-all', '--new-session', // Isolate session '--die-with-parent', // Prevent orphaned runaway processes ]; if (mergedAdditional.network) { bwrapArgs.push('--share-net'); } bwrapArgs.push( '--ro-bind', '/', '/', '--dev', // Creates a safe, minimal /dev (replaces --dev-bind) '/dev', '--proc', // Creates a fresh procfs for the unshared PID namespace '/proc', '--tmpfs', // Provides an isolated, writable /tmp directory '/tmp', ); const workspacePath = tryRealpath(this.options.workspace); const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try'; if (workspaceWrite) { bwrapArgs.push( '--bind-try', this.options.workspace, this.options.workspace, ); if (workspacePath !== this.options.workspace) { bwrapArgs.push('--bind-try', workspacePath, workspacePath); } } else { bwrapArgs.push( '--ro-bind-try', this.options.workspace, this.options.workspace, ); if (workspacePath !== this.options.workspace) { bwrapArgs.push('--ro-bind-try', workspacePath, workspacePath); } } const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath); if (worktreeGitDir) { bwrapArgs.push(bindFlag, worktreeGitDir, worktreeGitDir); } if (mainGitDir) { bwrapArgs.push(bindFlag, mainGitDir, mainGitDir); } const includeDirs = sanitizePaths(this.options.includeDirectories) || []; for (const includeDir of includeDirs) { try { const resolved = tryRealpath(includeDir); bwrapArgs.push('--ro-bind-try', resolved, resolved); } catch { // Ignore } } const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, ''); for (const allowedPath of allowedPaths) { const resolved = tryRealpath(allowedPath); if (!fs.existsSync(resolved)) { // If the path doesn't exist, we still want to allow access to its parent // if it's explicitly allowed, to enable creating it. try { const resolvedParent = tryRealpath(dirname(resolved)); bwrapArgs.push( req.command === '__write' ? '--bind-try' : bindFlag, resolvedParent, resolvedParent, ); } catch { // Ignore } continue; } const normalizedAllowedPath = normalize(resolved).replace(/\/$/, ''); if (normalizedAllowedPath !== normalizedWorkspace) { bwrapArgs.push('--bind-try', resolved, resolved); } } const additionalReads = sanitizePaths(mergedAdditional.fileSystem?.read) || []; for (const p of additionalReads) { try { const safeResolvedPath = tryRealpath(p); bwrapArgs.push('--ro-bind-try', safeResolvedPath, safeResolvedPath); } catch (e: unknown) { debugLogger.warn(e instanceof Error ? e.message : String(e)); } } const additionalWrites = sanitizePaths(mergedAdditional.fileSystem?.write) || []; for (const p of additionalWrites) { try { const safeResolvedPath = tryRealpath(p); bwrapArgs.push('--bind-try', safeResolvedPath, safeResolvedPath); } catch (e: unknown) { debugLogger.warn(e instanceof Error ? e.message : String(e)); } } for (const file of GOVERNANCE_FILES) { const filePath = join(this.options.workspace, file.path); touch(filePath, file.isDirectory); const realPath = tryRealpath(filePath); bwrapArgs.push('--ro-bind', filePath, filePath); if (realPath !== filePath) { bwrapArgs.push('--ro-bind', realPath, realPath); } } const forbiddenPaths = sanitizePaths(this.options.forbiddenPaths) || []; for (const p of forbiddenPaths) { let resolved: string; try { resolved = tryRealpath(p); // Forbidden paths should still resolve to block the real path if (!fs.existsSync(resolved)) continue; } catch (e: unknown) { debugLogger.warn( `Failed to resolve forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`, ); bwrapArgs.push('--ro-bind', '/dev/null', p); continue; } try { const stat = fs.statSync(resolved); if (stat.isDirectory()) { bwrapArgs.push('--tmpfs', resolved, '--remount-ro', resolved); } else { bwrapArgs.push('--ro-bind', '/dev/null', resolved); } } catch (e: unknown) { if (isErrnoException(e) && e.code === 'ENOENT') { bwrapArgs.push('--symlink', '/dev/null', resolved); } else { debugLogger.warn( `Failed to stat forbidden path ${resolved}: ${e instanceof Error ? e.message : String(e)}`, ); bwrapArgs.push('--ro-bind', '/dev/null', resolved); } } } // Mask secret files (.env, .env.*) bwrapArgs.push( ...(await this.getSecretFilesArgs(req.policy?.allowedPaths)), ); const bpfPath = getSeccompBpfPath(); bwrapArgs.push('--seccomp', '9'); bwrapArgs.push('--', finalCommand, ...finalArgs); const shArgs = [ '-c', 'bpf_path="$1"; shift; exec bwrap "$@" 9< "$bpf_path"', '_', bpfPath, ...bwrapArgs, ]; return { program: 'sh', args: shArgs, env: sanitizedEnv, cwd: req.cwd, }; } /** * Generates bubblewrap arguments to mask secret files. */ private async getSecretFilesArgs(allowedPaths?: string[]): Promise { const args: string[] = []; const maskPath = this.getMaskFilePath(); const paths = sanitizePaths(allowedPaths) || []; const searchDirs = new Set([this.options.workspace, ...paths]); const findPatterns = getSecretFileFindArgs(); for (const dir of searchDirs) { try { // Use the native 'find' command for performance and to catch nested secrets. // We limit depth to 3 to keep it fast while covering common nested structures. // We use -prune to skip heavy directories efficiently while matching dotfiles. const findResult = await spawnAsync('find', [ dir, '-maxdepth', '3', '-type', 'd', '(', '-name', '.git', '-o', '-name', 'node_modules', '-o', '-name', '.venv', '-o', '-name', '__pycache__', '-o', '-name', 'dist', '-o', '-name', 'build', ')', '-prune', '-o', '-type', 'f', ...findPatterns, '-print0', ]); const files = findResult.stdout.toString().split('\0'); for (const file of files) { if (file.trim()) { args.push('--bind', maskPath, file.trim()); } } } catch (e) { debugLogger.log( `LinuxSandboxManager: Failed to find or mask secret files in ${dir}`, e, ); } } return args; } }