| |
| |
| |
| |
| |
|
|
| import fs from 'node:fs'; |
| import { join, dirname } from 'node:path'; |
| import os from 'node:os'; |
| import { |
| type SandboxManager, |
| type GlobalSandboxOptions, |
| type SandboxRequest, |
| type SandboxedCommand, |
| type SandboxPermissions, |
| GOVERNANCE_FILES, |
| type ParsedSandboxDenial, |
| resolveSandboxPaths, |
| } from '../../services/sandboxManager.js'; |
| import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; |
| import { |
| sanitizeEnvironment, |
| getSecureSanitizationConfig, |
| } from '../../services/environmentSanitization.js'; |
| import { |
| isStrictlyApproved, |
| verifySandboxOverrides, |
| } from '../utils/commandUtils.js'; |
| import { assertValidPathString } from '../../utils/paths.js'; |
| import { |
| isKnownSafeCommand, |
| isDangerousCommand, |
| } from '../utils/commandSafety.js'; |
| import { |
| parsePosixSandboxDenials, |
| createSandboxDenialCache, |
| type SandboxDenialCache, |
| } from '../utils/sandboxDenialUtils.js'; |
| import { isErrnoException } from '../utils/fsUtils.js'; |
| import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js'; |
| import { buildBwrapArgs } from './bwrapArgsBuilder.js'; |
| import { |
| getCommandRoots, |
| initializeShellParsers, |
| stripShellWrapper, |
| } from '../../utils/shell-utils.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; |
| SYS_ptrace = 101; |
| } else if (arch === 'arm64') { |
| AUDIT_ARCH = 0xc00000b7; |
| SYS_ptrace = 117; |
| } else if (arch === 'arm') { |
| AUDIT_ARCH = 0x40000028; |
| SYS_ptrace = 26; |
| } else if (arch === 'ia32') { |
| AUDIT_ARCH = 0x40000003; |
| 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 }, |
| { code: 0x15, jt: 1, jf: 0, k: AUDIT_ARCH }, |
| { code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_KILL_PROCESS }, |
|
|
| { code: 0x20, jt: 0, jf: 0, k: 0 }, |
| { code: 0x15, jt: 0, jf: 1, k: SYS_ptrace }, |
| { code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_ERRNO | EPERM }, |
|
|
| { code: 0x06, jt: 0, jf: 0, k: SECCOMP_RET_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; |
|
|
| |
| process.on('exit', () => { |
| try { |
| fs.rmSync(tempDir, { recursive: true, force: true }); |
| } catch { |
| |
| } |
| }); |
|
|
| return bpfPath; |
| } |
|
|
| |
| |
| |
| function touch(filePath: string, isDirectory: boolean) { |
| assertValidPathString(filePath); |
| try { |
| |
| fs.lstatSync(filePath); |
| return; |
| } catch (e: unknown) { |
| if (isErrnoException(e) && e.code !== 'ENOENT') { |
| throw e; |
| } |
| } |
|
|
| if (isDirectory) { |
| fs.mkdirSync(filePath, { recursive: true }); |
| } else { |
| fs.mkdirSync(dirname(filePath), { recursive: true }); |
| fs.closeSync(fs.openSync(filePath, 'a')); |
| } |
| } |
|
|
| |
| |
| |
|
|
| export class LinuxSandboxManager implements SandboxManager { |
| private static maskFilePath: string | undefined; |
| private readonly denialCache: SandboxDenialCache = createSandboxDenialCache(); |
| private governanceFilesInitialized = false; |
|
|
| constructor(private readonly options: GlobalSandboxOptions) {} |
|
|
| private ensureGovernanceFilesExist(workspace: string): void { |
| if (this.governanceFilesInitialized) return; |
|
|
| |
| for (const file of GOVERNANCE_FILES) { |
| const filePath = join(workspace, file.path); |
| touch(filePath, file.isDirectory); |
| } |
|
|
| this.governanceFilesInitialized = true; |
| } |
|
|
| isKnownSafeCommand(args: string[], cwd?: string): boolean { |
| const effectiveCwd = cwd ?? this.options.workspace; |
| return isKnownSafeCommand(args, effectiveCwd, this.options.workspace); |
| } |
|
|
| isDangerousCommand(args: string[], cwd?: string): boolean { |
| const effectiveCwd = cwd ?? this.options.workspace; |
| return isDangerousCommand(args, effectiveCwd, this.options.workspace); |
| } |
|
|
| parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { |
| return parsePosixSandboxDenials(result, this.denialCache); |
| } |
|
|
| getWorkspace(): string { |
| return this.options.workspace; |
| } |
|
|
| getOptions(): GlobalSandboxOptions { |
| return this.options; |
| } |
|
|
| 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; |
|
|
| |
| process.on('exit', () => { |
| try { |
| fs.rmSync(tempDir, { recursive: true, force: true }); |
| } catch { |
| |
| } |
| }); |
|
|
| return maskPath; |
| } |
|
|
| async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> { |
| 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; |
|
|
| |
| if (command === '__read') { |
| command = 'cat'; |
| } else if (command === '__write') { |
| command = 'sh'; |
| args = ['-c', 'cat > "$1"', '_', ...args]; |
| } |
|
|
| await initializeShellParsers(); |
| const fullCmd = [command, ...args].join(' '); |
| const stripped = stripShellWrapper(fullCmd); |
| const roots = getCommandRoots(stripped).filter( |
| (r) => r !== 'shopt' && r !== 'set', |
| ); |
| const commandName = roots.length > 0 ? roots[0] : join(command); |
| const isGitCommand = roots.includes('git'); |
|
|
| const isApproved = allowOverrides |
| ? await isStrictlyApproved( |
| { ...req, command, args }, |
| this.options.modeConfig?.approvedTools, |
| ) |
| : false; |
| const isYolo = this.options.modeConfig?.yolo ?? false; |
| const workspaceWrite = !isReadonlyMode || isApproved || isYolo; |
|
|
| const networkAccess = |
| this.options.modeConfig?.network || req.policy?.networkAccess || isYolo; |
|
|
| 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, |
| }; |
|
|
| |
| |
| if (workspaceWrite && isGitCommand) { |
| const gitDir = join(this.options.workspace, '.git'); |
| if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) { |
| mergedAdditional.fileSystem!.write!.push(gitDir); |
| } |
| } |
|
|
| const { command: finalCommand, args: finalArgs } = handleReadWriteCommands( |
| req, |
| mergedAdditional, |
| this.options.workspace, |
| [ |
| ...(req.policy?.allowedPaths || []), |
| ...(this.options.includeDirectories || []), |
| ], |
| ); |
|
|
| const sanitizationConfig = getSecureSanitizationConfig( |
| req.policy?.sanitizationConfig, |
| ); |
|
|
| const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); |
|
|
| const resolvedPaths = await resolveSandboxPaths( |
| this.options, |
| req, |
| mergedAdditional, |
| ); |
|
|
| this.ensureGovernanceFilesExist(resolvedPaths.workspace.resolved); |
|
|
| const bwrapArgs = await buildBwrapArgs({ |
| resolvedPaths, |
| workspaceWrite, |
| networkAccess: mergedAdditional.network ?? false, |
| maskFilePath: this.getMaskFilePath(), |
| isReadOnlyCommand: req.command === '__read', |
| }); |
|
|
| const bpfPath = getSeccompBpfPath(); |
| bwrapArgs.push('--seccomp', '9'); |
|
|
| const argsPath = this.writeArgsToTempFile(bwrapArgs); |
|
|
| const shArgs = [ |
| '-c', |
| 'bpf_path="$1"; args_path="$2"; shift 2; exec bwrap --args 8 "$@" 8< "$args_path" 9< "$bpf_path"', |
| '_', |
| bpfPath, |
| argsPath, |
| '--', |
| finalCommand, |
| ...finalArgs, |
| ]; |
|
|
| return { |
| program: 'sh', |
| args: shArgs, |
| env: sanitizedEnv, |
| cwd: req.cwd, |
| cleanup: () => { |
| try { |
| fs.unlinkSync(argsPath); |
| } catch { |
| |
| } |
| }, |
| }; |
| } |
|
|
| private writeArgsToTempFile(args: string[]): string { |
| const tempFile = join( |
| os.tmpdir(), |
| `gemini-cli-bwrap-args-${Date.now()}-${Math.random().toString(36).slice(2)}.args`, |
| ); |
| const content = Buffer.from(args.join('\0') + '\0'); |
| fs.writeFileSync(tempFile, content, { mode: 0o600 }); |
| return tempFile; |
| } |
| } |
|
|