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:
Zheyuan Lin
2026-03-05 13:39:57 -05:00
committed by GitHub
parent c7e2dbe0cf
commit 291639633f
6 changed files with 235 additions and 27 deletions

View File

@@ -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}}`).

View File

@@ -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');
});
});
});

View File

@@ -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) {

View File

@@ -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' }),
);
});
});
});

View File

@@ -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',
});

View File

@@ -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;
}