From 291639633f90d230e97573fe9fac8a8c76816222 Mon Sep 17 00:00:00 2001 From: Zheyuan Lin <137805563+Zheyuan-Lin@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:39:57 -0500 Subject: [PATCH] feat(cli): add native gVisor (runsc) sandboxing support (#21062) Co-authored-by: Zheyuan Co-authored-by: Kartik Angiras --- docs/cli/sandbox.md | 28 +++++- packages/cli/src/config/sandboxConfig.test.ts | 92 ++++++++++++++++++- packages/cli/src/config/sandboxConfig.ts | 26 ++++-- packages/cli/src/utils/sandbox.test.ts | 53 +++++++++++ packages/cli/src/utils/sandbox.ts | 61 ++++++++---- packages/core/src/config/config.ts | 2 +- 6 files changed, 235 insertions(+), 27 deletions(-) diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 1d1b18351d..ec7e88f624 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -50,7 +50,31 @@ Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. -### 3. LXC/LXD (Linux only, experimental) +### 3. gVisor / runsc (Linux only) + +Strongest isolation available: runs containers inside a user-space kernel via +[gVisor](https://github.com/google/gvisor). gVisor intercepts all container +system calls and handles them in a sandboxed kernel written in Go, providing a +strong security barrier between AI operations and the host OS. + +**Prerequisites:** + +- Linux (gVisor supports Linux only) +- Docker installed and running +- gVisor/runsc runtime configured + +When you set `sandbox: "runsc"`, Gemini CLI runs +`docker run --runtime=runsc ...` to execute containers with gVisor isolation. +runsc is not auto-detected; you must specify it explicitly (e.g. +`GEMINI_SANDBOX=runsc` or `sandbox: "runsc"`). + +To set up runsc: + +1. Install the runsc binary. +2. Configure the Docker daemon to use the runsc runtime. +3. Verify the installation. + +### 4. LXC/LXD (Linux only, experimental) Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC containers run a complete Linux system with `systemd`, `snapd`, and other system @@ -133,7 +157,7 @@ gemini -p "run the test suite" 1. **Command flag**: `-s` or `--sandbox` 2. **Environment variable**: - `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|lxc` + `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc` 3. **Settings file**: `"sandbox": true` in the `tools` object of your `settings.json` file (e.g., `{"tools": {"sandbox": true}}`). diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index 8083b0ddf1..51c4f7d83c 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -97,7 +97,7 @@ describe('loadSandboxConfig', () => { it('should throw if GEMINI_SANDBOX is an invalid command', async () => { process.env['GEMINI_SANDBOX'] = 'invalid-command'; await expect(loadSandboxConfig({}, {})).rejects.toThrow( - "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, lxc", + "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc", ); }); @@ -194,7 +194,7 @@ describe('loadSandboxConfig', () => { await expect( loadSandboxConfig({}, { sandbox: 'invalid-command' }), ).rejects.toThrow( - "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec", + "Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, runsc, lxc", ); }); }); @@ -247,4 +247,92 @@ describe('loadSandboxConfig', () => { }, ); }); + + describe('with sandbox: runsc (gVisor)', () => { + beforeEach(() => { + mockedOsPlatform.mockReturnValue('linux'); + mockedCommandExistsSync.mockReturnValue(true); + }); + + it('should use runsc via CLI argument on Linux', async () => { + const config = await loadSandboxConfig({}, { sandbox: 'runsc' }); + + expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should use runsc via GEMINI_SANDBOX environment variable', async () => { + process.env['GEMINI_SANDBOX'] = 'runsc'; + const config = await loadSandboxConfig({}, {}); + + expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should use runsc via settings file', async () => { + const config = await loadSandboxConfig( + { tools: { sandbox: 'runsc' } }, + {}, + ); + + expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('runsc'); + expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker'); + }); + + it('should prioritize GEMINI_SANDBOX over CLI and settings', async () => { + process.env['GEMINI_SANDBOX'] = 'runsc'; + const config = await loadSandboxConfig( + { tools: { sandbox: 'docker' } }, + { sandbox: 'podman' }, + ); + + expect(config).toEqual({ command: 'runsc', image: 'default/image' }); + }); + + it('should reject runsc on macOS (Linux-only)', async () => { + mockedOsPlatform.mockReturnValue('darwin'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); + }); + + it('should reject runsc on Windows (Linux-only)', async () => { + mockedOsPlatform.mockReturnValue('win32'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); + }); + + it('should throw if runsc binary not found', async () => { + mockedCommandExistsSync.mockReturnValue(false); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + "Missing sandbox command 'runsc' (from GEMINI_SANDBOX)", + ); + }); + + it('should throw if Docker not available (runsc requires Docker)', async () => { + mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'runsc'); + + await expect(loadSandboxConfig({}, { sandbox: 'runsc' })).rejects.toThrow( + "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.", + ); + }); + + it('should NOT auto-detect runsc when both runsc and docker available', async () => { + mockedCommandExistsSync.mockImplementation( + (cmd) => cmd === 'runsc' || cmd === 'docker', + ); + + const config = await loadSandboxConfig({}, { sandbox: true }); + + expect(config?.command).toBe('docker'); + expect(config?.command).not.toBe('runsc'); + }); + }); }); diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index bb812cd317..968d3e427a 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -27,6 +27,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ 'docker', 'podman', 'sandbox-exec', + 'runsc', 'lxc', ]; @@ -64,17 +65,30 @@ function getSandboxCommand( )}`, ); } - // confirm that specified command exists - if (commandExists.sync(sandbox)) { - return sandbox; + // runsc (gVisor) is only supported on Linux + if (sandbox === 'runsc' && os.platform() !== 'linux') { + throw new FatalSandboxError( + 'gVisor (runsc) sandboxing is only supported on Linux', + ); } - throw new FatalSandboxError( - `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, - ); + // confirm that specified command exists + if (!commandExists.sync(sandbox)) { + throw new FatalSandboxError( + `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, + ); + } + // runsc uses Docker with --runtime=runsc; both must be available (prioritize runsc when explicitly chosen) + if (sandbox === 'runsc' && !commandExists.sync('docker')) { + throw new FatalSandboxError( + "runsc (gVisor) requires Docker. Install Docker, or use sandbox: 'docker'.", + ); + } + return sandbox; } // look for seatbelt, docker, or podman, in that order // for container-based sandboxing, require sandbox to be enabled explicitly + // note: runsc is NOT auto-detected, it must be explicitly specified if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) { return 'sandbox-exec'; } else if (commandExists.sync('docker') && sandbox === true) { diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 3b66d1a6de..fa562f7ad6 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -573,4 +573,57 @@ describe('sandbox', () => { }); }); }); + + describe('gVisor (runsc)', () => { + it('should use docker with --runtime=runsc on Linux', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + const config: SandboxConfig = { + command: 'runsc', + image: 'gemini-cli-sandbox', + }; + + // Mock image check + interface MockProcessWithStdout extends EventEmitter { + stdout: EventEmitter; + } + const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout; + mockImageCheckProcess.stdout = new EventEmitter(); + vi.mocked(spawn).mockImplementationOnce(() => { + setTimeout(() => { + mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id')); + mockImageCheckProcess.emit('close', 0); + }, 1); + return mockImageCheckProcess as unknown as ReturnType; + }); + + // Mock docker run + const mockSpawnProcess = new EventEmitter() as unknown as ReturnType< + typeof spawn + >; + mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => { + if (event === 'close') { + setTimeout(() => cb(0), 10); + } + return mockSpawnProcess; + }); + vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess); + + await start_sandbox(config, [], undefined, ['arg1']); + + // Verify docker (not runsc) is called for image check + expect(spawn).toHaveBeenNthCalledWith( + 1, + 'docker', + expect.arrayContaining(['images', '-q', 'gemini-cli-sandbox']), + ); + + // Verify docker run includes --runtime=runsc + expect(spawn).toHaveBeenNthCalledWith( + 2, + 'docker', + expect.arrayContaining(['run', '--runtime=runsc']), + expect.objectContaining({ stdio: 'inherit' }), + ); + }); + }); }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 94811107fc..df9a88cc4c 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -215,7 +215,10 @@ export async function start_sandbox( return await start_lxc_sandbox(config, nodeArgs, cliArgs); } - debugLogger.log(`hopping into sandbox (command: ${config.command}) ...`); + // runsc uses docker with --runtime=runsc + const command = config.command === 'runsc' ? 'docker' : config.command; + + debugLogger.log(`hopping into sandbox (command: ${command}) ...`); // determine full path for gemini-cli to distinguish linked vs installed setting const gcPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : ''; @@ -258,7 +261,7 @@ export async function start_sandbox( stdio: 'inherit', env: { ...process.env, - GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) + GEMINI_SANDBOX: command, // in case sandbox is enabled via flags (see config.ts under cli package) }, }, ); @@ -266,9 +269,7 @@ export async function start_sandbox( } // stop if image is missing - if ( - !(await ensureSandboxImageIsPresent(config.command, image, cliConfig)) - ) { + if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) { const remedy = image === LOCAL_DEV_SANDBOX_IMAGE_NAME ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' @@ -282,11 +283,17 @@ export async function start_sandbox( // run init binary inside container to forward signals & reap zombies const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; + // add runsc runtime if using runsc + if (config.command === 'runsc') { + args.push('--runtime=runsc'); + } + // add custom flags from SANDBOX_FLAGS if (process.env['SANDBOX_FLAGS']) { const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter( (f): f is string => typeof f === 'string', ); + args.push(...flags); } @@ -422,7 +429,7 @@ export async function start_sandbox( // if using proxy, switch to internal networking through proxy if (proxy) { execSync( - `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`, + `${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create --internal ${SANDBOX_NETWORK_NAME}`, ); args.push('--network', SANDBOX_NETWORK_NAME); // if proxy command is set, create a separate network w/ host access (i.e. non-internal) @@ -430,7 +437,7 @@ export async function start_sandbox( // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation if (proxyCommand) { execSync( - `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`, + `${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`, ); } } @@ -449,7 +456,7 @@ export async function start_sandbox( } else { let index = 0; const containerNameCheck = ( - await execAsync(`${config.command} ps -a --format "{{.Names}}"`) + await execAsync(`${command} ps -a --format "{{.Names}}"`) ).stdout.trim(); while (containerNameCheck.includes(`${imageName}-${index}`)) { index++; @@ -599,7 +606,7 @@ export async function start_sandbox( args.push('--env', `SANDBOX=${containerName}`); // for podman only, use empty --authfile to skip unnecessary auth refresh overhead - if (config.command === 'podman') { + if (command === 'podman') { const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); args.push('--authfile', emptyAuthFilePath); @@ -663,16 +670,38 @@ export async function start_sandbox( if (proxyCommand) { // run proxyCommand in its own container - const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`; - proxyProcess = spawn(proxyContainerCommand, { + // build args array to prevent command injection + const proxyContainerArgs = [ + 'run', + '--rm', + '--init', + ...(userFlag ? userFlag.split(' ') : []), + '--name', + SANDBOX_PROXY_NAME, + '--network', + SANDBOX_PROXY_NAME, + '-p', + '8877:8877', + '-v', + `${process.cwd()}:${workdir}`, + '--workdir', + workdir, + image, + // proxyCommand may be a shell string, so parse it into tokens safely + ...parse(proxyCommand, process.env).filter( + (f): f is string => typeof f === 'string', + ), + ]; + + proxyProcess = spawn(command, proxyContainerArgs, { stdio: ['ignore', 'pipe', 'pipe'], - shell: true, + shell: false, // <-- no shell; args are passed directly detached: true, }); // install handlers to stop proxy on exit/signal const stopProxy = () => { debugLogger.log('stopping proxy container ...'); - execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`); + execSync(`${command} rm -f ${SANDBOX_PROXY_NAME}`); }; process.off('exit', stopProxy); process.on('exit', stopProxy); @@ -693,7 +722,7 @@ export async function start_sandbox( process.kill(-sandboxProcess.pid, 'SIGTERM'); } throw new FatalSandboxError( - `Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, + `Proxy container command '${command} ${proxyContainerArgs.join(' ')}' exited with code ${code}, signal ${signal}`, ); }); debugLogger.log('waiting for proxy to start ...'); @@ -703,13 +732,13 @@ export async function start_sandbox( // connect proxy container to sandbox network // (workaround for older versions of docker that don't support multiple --network args) await execAsync( - `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, + `${command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, ); } // spawn child and let it inherit stdio process.stdin.pause(); - sandboxProcess = spawn(config.command, args, { + sandboxProcess = spawn(command, args, { stdio: 'inherit', }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 8c341073eb..26f199f9c0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -446,7 +446,7 @@ export enum AuthProviderType { } export interface SandboxConfig { - command: 'docker' | 'podman' | 'sandbox-exec' | 'lxc'; + command: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc'; image: string; }