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
47 changes: 46 additions & 1 deletion docs/cli/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,50 @@ 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)

Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC
containers run a complete Linux system with `systemd`, `snapd`, and other system
services. This is ideal for tools that don't work in standard Docker containers,
such as Snapcraft and Rockcraft.

**Prerequisites**:

- Linux only.
- LXC/LXD must be installed (`snap install lxd` or `apt install lxd`).
- A container must be created and running before starting Gemini CLI. Gemini
does **not** create the container automatically.

**Quick setup**:

```bash
# Initialize LXD (first time only)
lxd init --auto

# Create and start an Ubuntu container
lxc launch ubuntu:24.04 gemini-sandbox

# Enable LXC sandboxing
export GEMINI_SANDBOX=lxc
gemini -p "build the project"
```

**Custom container name**:

```bash
export GEMINI_SANDBOX=lxc
export GEMINI_SANDBOX_IMAGE=my-snapcraft-container
gemini -p "build the snap"
```

**Limitations**:

- Linux only (LXC is not available on macOS or Windows).
- The container must already exist and be running.
- The workspace directory is bind-mounted into the container at the same
absolute path — the path must be writable inside the container.
- Used with tools like Snapcraft or Rockcraft that require a full system.

## Quickstart

```bash
Expand Down Expand Up @@ -88,7 +132,8 @@ gemini -p "run the test suite"
### Enable sandboxing (in order of precedence)

1. **Command flag**: `-s` or `--sandbox`
2. **Environment variable**: `GEMINI_SANDBOX=true|docker|podman|sandbox-exec`
2. **Environment variable**:
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|lxc`
3. **Settings file**: `"sandbox": true` in the `tools` object of your
`settings.json` file (e.g., `{"tools": {"sandbox": true}}`).

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,8 @@ their corresponding top-level category object in your `settings.json` file.

- **`tools.sandbox`** (boolean | string):
- **Description:** Sandbox execution environment. Set to a boolean to enable
or disable the sandbox, or provide a string path to a sandbox profile.
or disable the sandbox, provide a string path to a sandbox profile, or
specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
- **Default:** `undefined`
- **Requires restart:** Yes

Expand Down
18 changes: 17 additions & 1 deletion 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",
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec, lxc",
);
});

Expand All @@ -108,6 +108,22 @@ describe('loadSandboxConfig', () => {
"Missing sandbox command 'docker' (from GEMINI_SANDBOX)",
);
});

it('should use lxc if GEMINI_SANDBOX=lxc and it exists', async () => {
process.env['GEMINI_SANDBOX'] = 'lxc';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'lxc', image: 'default/image' });
expect(mockedCommandExistsSync).toHaveBeenCalledWith('lxc');
});

it('should throw if GEMINI_SANDBOX=lxc but lxc command does not exist', async () => {
process.env['GEMINI_SANDBOX'] = 'lxc';
mockedCommandExistsSync.mockReturnValue(false);
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
"Missing sandbox command 'lxc' (from GEMINI_SANDBOX)",
);
});
});

describe('with sandbox: true', () => {
Expand Down
4 changes: 4 additions & 0 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',
'lxc',
];

function isSandboxCommand(value: string): value is SandboxConfig['command'] {
Expand Down Expand Up @@ -91,6 +92,9 @@ function getSandboxCommand(
}

return '';
// Note: 'lxc' is intentionally not auto-detected because it requires a
// pre-existing, running container managed by the user. Use
// GEMINI_SANDBOX=lxc or sandbox: "lxc" in settings to enable it.
}

export async function loadSandboxConfig(
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,7 +1236,8 @@ const SETTINGS_SCHEMA = {
ref: 'BooleanOrString',
description: oneLine`
Sandbox execution environment.
Set to a boolean to enable or disable the sandbox, or provide a string path to a sandbox profile.
Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile,
or specify an explicit sandbox command (e.g., "docker", "podman", "lxc").
`,
showInDialog: false,
},
Expand Down
101 changes: 100 additions & 1 deletion packages/cli/src/utils/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, exec, execSync } from 'node:child_process';
import { spawn, exec, execFile, execSync } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
import { start_sandbox } from './sandbox.js';
Expand Down Expand Up @@ -50,6 +50,26 @@ vi.mock('node:util', async (importOriginal) => {
return { stdout: '', stderr: '' };
};
}
if (fn === execFile) {
return async (file: string, args: string[]) => {
if (file === 'lxc' && args[0] === 'list') {
const output = process.env['TEST_LXC_LIST_OUTPUT'];
if (output === 'throw') {
throw new Error('lxc command not found');
}
return { stdout: output ?? '[]', stderr: '' };
}
if (
file === 'lxc' &&
args[0] === 'config' &&
args[1] === 'device' &&
args[2] === 'add'
) {
return { stdout: '', stderr: '' };
}
return { stdout: '', stderr: '' };
};
}
return actual.promisify(fn);
},
};
Expand Down Expand Up @@ -473,5 +493,84 @@ describe('sandbox', () => {
expect(entrypointCmd).toContain('useradd');
expect(entrypointCmd).toContain('su -p gemini');
});

describe('LXC sandbox', () => {
const LXC_RUNNING = JSON.stringify([
{ name: 'gemini-sandbox', status: 'Running' },
]);
const LXC_STOPPED = JSON.stringify([
{ name: 'gemini-sandbox', status: 'Stopped' },
]);

beforeEach(() => {
delete process.env['TEST_LXC_LIST_OUTPUT'];
});

it('should run lxc exec with correct args for a running container', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING;
const config: SandboxConfig = {
command: 'lxc',
image: 'gemini-sandbox',
};

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).mockImplementation((cmd) => {
if (cmd === 'lxc') {
return mockSpawnProcess;
}
return new EventEmitter() as unknown as ReturnType<typeof spawn>;
});

const promise = start_sandbox(config, [], undefined, ['arg1']);
await expect(promise).resolves.toBe(0);

expect(spawn).toHaveBeenCalledWith(
'lxc',
expect.arrayContaining(['exec', 'gemini-sandbox', '--cwd']),
expect.objectContaining({ stdio: 'inherit' }),
);
});

it('should throw FatalSandboxError if lxc list fails', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
const config: SandboxConfig = {
command: 'lxc',
image: 'gemini-sandbox',
};

await expect(start_sandbox(config)).rejects.toThrow(
/Failed to query LXC container/,
);
});

it('should throw FatalSandboxError if container is not running', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED;
const config: SandboxConfig = {
command: 'lxc',
image: 'gemini-sandbox',
};

await expect(start_sandbox(config)).rejects.toThrow(/is not running/);
});

it('should throw FatalSandboxError if container is not found in list', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = '[]';
const config: SandboxConfig = {
command: 'lxc',
image: 'gemini-sandbox',
};

await expect(start_sandbox(config)).rejects.toThrow(/not found/);
});
});
});
});
Loading
Loading