Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions docs/cli/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}}`).

Expand Down
92 changes: 90 additions & 2 deletions packages/cli/src/config/sandboxConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});

Expand Down Expand Up @@ -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",
);
});
});
Expand Down Expand Up @@ -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');
});
});
});
24 changes: 19 additions & 5 deletions packages/cli/src/config/sandboxConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'docker',
'podman',
'sandbox-exec',
'runsc',
'lxc',
];

Expand Down Expand Up @@ -64,17 +65,30 @@ function getSandboxCommand(
)}`,
);
}
// runsc (gVisor) is only supported on Linux
if (sandbox === 'runsc' && os.platform() !== 'linux') {
throw new FatalSandboxError(
'gVisor (runsc) sandboxing is only supported on Linux',
);
}
// confirm that specified command exists
if (commandExists.sync(sandbox)) {
return sandbox;
if (!commandExists.sync(sandbox)) {
throw new FatalSandboxError(
`Missing sandbox command '${sandbox}' (from GEMINI_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) {
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/utils/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
);
});
});
});
Loading
Loading