2026-03-16 21:34:48 +00:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2026 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
2026-03-24 04:04:17 +00:00
|
|
|
import fs from 'node:fs';
|
2026-04-01 16:17:10 -04:00
|
|
|
import { join, dirname } from 'node:path';
|
2026-03-17 20:29:13 +00:00
|
|
|
import os from 'node:os';
|
2026-03-16 21:34:48 +00:00
|
|
|
import {
|
|
|
|
|
type SandboxManager,
|
2026-03-23 11:43:58 -04:00
|
|
|
type GlobalSandboxOptions,
|
2026-03-16 21:34:48 +00:00
|
|
|
type SandboxRequest,
|
|
|
|
|
type SandboxedCommand,
|
2026-03-25 18:58:45 -07:00
|
|
|
type SandboxPermissions,
|
2026-03-24 04:04:17 +00:00
|
|
|
GOVERNANCE_FILES,
|
2026-03-26 15:10:15 -07:00
|
|
|
type ParsedSandboxDenial,
|
2026-04-01 12:27:55 -04:00
|
|
|
resolveSandboxPaths,
|
2026-03-16 21:34:48 +00:00
|
|
|
} from '../../services/sandboxManager.js';
|
2026-03-26 15:10:15 -07:00
|
|
|
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
|
2026-03-16 21:34:48 +00:00
|
|
|
import {
|
|
|
|
|
sanitizeEnvironment,
|
|
|
|
|
getSecureSanitizationConfig,
|
|
|
|
|
} from '../../services/environmentSanitization.js';
|
2026-03-25 18:58:45 -07:00
|
|
|
import {
|
|
|
|
|
isStrictlyApproved,
|
|
|
|
|
verifySandboxOverrides,
|
|
|
|
|
getCommandName,
|
|
|
|
|
} from '../utils/commandUtils.js';
|
2026-03-26 20:35:21 +00:00
|
|
|
import {
|
|
|
|
|
isKnownSafeCommand,
|
|
|
|
|
isDangerousCommand,
|
|
|
|
|
} from '../utils/commandSafety.js';
|
2026-03-26 15:10:15 -07:00
|
|
|
import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js';
|
2026-03-31 12:39:51 -07:00
|
|
|
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
2026-04-01 16:17:10 -04:00
|
|
|
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
|
2026-03-16 21:34:48 +00:00
|
|
|
|
2026-03-17 20:29:13 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 20:35:21 +00:00
|
|
|
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-'));
|
|
|
|
|
const bpfPath = join(tempDir, 'seccomp.bpf');
|
2026-03-24 04:04:17 +00:00
|
|
|
fs.writeFileSync(bpfPath, buf);
|
2026-03-17 20:29:13 +00:00
|
|
|
cachedBpfPath = bpfPath;
|
2026-03-26 20:35:21 +00:00
|
|
|
|
|
|
|
|
// Cleanup on exit
|
|
|
|
|
process.on('exit', () => {
|
|
|
|
|
try {
|
|
|
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore errors
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-17 20:29:13 +00:00
|
|
|
return bpfPath;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 04:04:17 +00:00
|
|
|
/**
|
|
|
|
|
* 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'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 21:34:48 +00:00
|
|
|
/**
|
|
|
|
|
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
|
|
|
|
|
*/
|
2026-03-25 18:58:45 -07:00
|
|
|
|
2026-03-16 21:34:48 +00:00
|
|
|
export class LinuxSandboxManager implements SandboxManager {
|
2026-03-26 20:35:21 +00:00
|
|
|
private static maskFilePath: string | undefined;
|
|
|
|
|
|
2026-03-27 12:57:26 -04:00
|
|
|
constructor(private readonly options: GlobalSandboxOptions) {}
|
2026-03-16 21:34:48 +00:00
|
|
|
|
2026-03-25 17:54:45 +00:00
|
|
|
isKnownSafeCommand(args: string[]): boolean {
|
|
|
|
|
return isKnownSafeCommand(args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isDangerousCommand(args: string[]): boolean {
|
|
|
|
|
return isDangerousCommand(args);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 15:10:15 -07:00
|
|
|
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
|
|
|
|
|
return parsePosixSandboxDenials(result);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 20:35:21 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 21:34:48 +00:00
|
|
|
async prepareCommand(req: SandboxRequest): Promise<SandboxedCommand> {
|
2026-03-25 18:58:45 -07:00
|
|
|
const isReadonlyMode = this.options.modeConfig?.readonly ?? true;
|
|
|
|
|
const allowOverrides = this.options.modeConfig?.allowOverrides ?? true;
|
|
|
|
|
|
|
|
|
|
verifySandboxOverrides(allowOverrides, req.policy);
|
|
|
|
|
|
2026-03-31 22:06:50 +00:00
|
|
|
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 });
|
2026-03-25 18:58:45 -07:00
|
|
|
const isApproved = allowOverrides
|
2026-03-31 22:06:50 +00:00
|
|
|
? await isStrictlyApproved(
|
|
|
|
|
{ ...req, command, args },
|
|
|
|
|
this.options.modeConfig?.approvedTools,
|
|
|
|
|
)
|
2026-03-25 18:58:45 -07:00
|
|
|
: false;
|
|
|
|
|
const workspaceWrite = !isReadonlyMode || isApproved;
|
|
|
|
|
const networkAccess =
|
2026-03-27 17:14:35 -07:00
|
|
|
this.options.modeConfig?.network || req.policy?.networkAccess || false;
|
2026-03-25 18:58:45 -07:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 12:39:51 -07:00
|
|
|
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
|
|
|
|
|
req,
|
|
|
|
|
mergedAdditional,
|
|
|
|
|
this.options.workspace,
|
|
|
|
|
req.policy?.allowedPaths,
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-16 21:34:48 +00:00
|
|
|
const sanitizationConfig = getSecureSanitizationConfig(
|
2026-03-23 11:43:58 -04:00
|
|
|
req.policy?.sanitizationConfig,
|
2026-03-16 21:34:48 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig);
|
|
|
|
|
|
2026-04-01 12:27:55 -04:00
|
|
|
const { allowed: allowedPaths, forbidden: forbiddenPaths } =
|
|
|
|
|
await resolveSandboxPaths(this.options, req);
|
2026-03-31 22:06:50 +00:00
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
for (const file of GOVERNANCE_FILES) {
|
|
|
|
|
const filePath = join(this.options.workspace, file.path);
|
|
|
|
|
touch(filePath, file.isDirectory);
|
|
|
|
|
}
|
2026-03-24 21:23:51 -04:00
|
|
|
|
2026-04-01 16:17:10 -04:00
|
|
|
const bwrapArgs = await buildBwrapArgs({
|
|
|
|
|
workspace: this.options.workspace,
|
|
|
|
|
workspaceWrite,
|
|
|
|
|
networkAccess,
|
|
|
|
|
allowedPaths,
|
|
|
|
|
forbiddenPaths,
|
|
|
|
|
additionalPermissions: mergedAdditional,
|
|
|
|
|
includeDirectories: this.options.includeDirectories || [],
|
|
|
|
|
maskFilePath: this.getMaskFilePath(),
|
|
|
|
|
isWriteCommand: req.command === '__write',
|
|
|
|
|
});
|
2026-03-26 20:35:21 +00:00
|
|
|
|
2026-03-25 18:58:45 -07:00
|
|
|
const bpfPath = getSeccompBpfPath();
|
|
|
|
|
bwrapArgs.push('--seccomp', '9');
|
2026-04-01 16:17:10 -04:00
|
|
|
|
|
|
|
|
const argsPath = this.writeArgsToTempFile(bwrapArgs);
|
2026-03-25 18:58:45 -07:00
|
|
|
|
|
|
|
|
const shArgs = [
|
|
|
|
|
'-c',
|
2026-04-01 16:17:10 -04:00
|
|
|
'bpf_path="$1"; args_path="$2"; shift 2; exec bwrap --args 8 "$@" 8< "$args_path" 9< "$bpf_path"',
|
2026-03-25 18:58:45 -07:00
|
|
|
'_',
|
|
|
|
|
bpfPath,
|
2026-04-01 16:17:10 -04:00
|
|
|
argsPath,
|
|
|
|
|
'--',
|
|
|
|
|
finalCommand,
|
|
|
|
|
...finalArgs,
|
2026-03-25 18:58:45 -07:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
program: 'sh',
|
|
|
|
|
args: shArgs,
|
|
|
|
|
env: sanitizedEnv,
|
|
|
|
|
cwd: req.cwd,
|
2026-04-01 16:17:10 -04:00
|
|
|
cleanup: () => {
|
|
|
|
|
try {
|
|
|
|
|
fs.unlinkSync(argsPath);
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore cleanup errors
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-03-25 18:58:45 -07:00
|
|
|
};
|
2026-03-24 21:23:51 -04:00
|
|
|
}
|
2026-03-26 20:35:21 +00:00
|
|
|
|
2026-04-01 16:17:10 -04:00
|
|
|
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;
|
2026-03-26 20:35:21 +00:00
|
|
|
}
|
2026-03-16 21:34:48 +00:00
|
|
|
}
|