mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-11 05:41:08 -07:00
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
/**
|
|
* @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<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;
|
|
|
|
// 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<string[]> {
|
|
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;
|
|
}
|
|
}
|