mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat(cli): add native gVisor (runsc) sandboxing support (#21062)
Co-authored-by: Zheyuan <zlin252@emory.edu> Co-authored-by: Kartik Angiras <angiraskartik@gmail.com>
This commit is contained in:
@@ -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}}`).
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
|
||||
'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) {
|
||||
|
||||
@@ -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<typeof spawn>;
|
||||
});
|
||||
|
||||
// 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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user