WIP: macOS tool sandbox

This commit is contained in:
galz10
2026-03-06 14:57:42 -08:00
parent 9773a084c9
commit 3d5af8b350
38 changed files with 1475 additions and 252 deletions
+8 -8
View File
@@ -31,18 +31,13 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'lxc',
];
function isSandboxCommand(value: string): value is SandboxConfig['command'] {
return (VALID_SANDBOX_COMMANDS as readonly string[]).includes(value);
function isSandboxCommand(value: string | undefined): value is SandboxConfig['command'] {
return (VALID_SANDBOX_COMMANDS as readonly (string | undefined)[]).includes(value);
}
function getSandboxCommand(
sandbox?: boolean | string | null,
): SandboxConfig['command'] | '' {
// If the SANDBOX env var is set, we're already inside the sandbox.
if (process.env['SANDBOX']) {
return '';
}
// note environment variable takes precedence over argument (from command line or settings)
const environmentConfiguredSandbox =
process.env['GEMINI_SANDBOX']?.toLowerCase().trim() ?? '';
@@ -124,5 +119,10 @@ export async function loadSandboxConfig(
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined;
const enabled = !!(command === 'sandbox-exec' || (command && image));
if (enabled) {
console.error(`[DEBUG] Sandbox Enabled: ${command}${image ? ` (image: ${image})` : ''}`);
}
return enabled ? { command: command as SandboxConfig['command'], image, enabled: true } : undefined;
}
+8 -1
View File
@@ -234,6 +234,7 @@ vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn().mockResolvedValue({
command: 'docker',
image: 'test-image',
enabled: true,
}),
}));
@@ -618,13 +619,18 @@ describe('gemini.tsx main function kitty protocol', () => {
const mockConfig = createMockConfig({
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => ({ command: 'docker', image: 'test-image' }),
getSandbox: () => ({
command: 'docker',
image: 'test-image',
enabled: true,
}),
});
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'docker',
image: 'test-image',
enabled: true,
});
process.env['GEMINI_API_KEY'] = 'test-key';
@@ -914,6 +920,7 @@ describe('gemini.tsx main function exit codes', () => {
it('should exit with 41 for auth failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue({
enabled: true,
command: 'docker',
image: 'test-image',
});
+3 -63
View File
@@ -15,7 +15,6 @@ import { createHash } from 'node:crypto';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import { start_sandbox } from './utils/sandbox.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import {
loadTrustedFolders,
@@ -94,11 +93,6 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import {
relaunchAppInChildProcess,
relaunchOnExitCode,
} from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
@@ -503,63 +497,9 @@ export async function main() {
// Run deferred command now that we have admin settings.
await runDeferredCommand(settings.merged);
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
const memoryArgs = settings.merged.advanced.autoConfigureMemory
? getNodeMemoryArgs(isDebugMode)
: [];
const sandboxConfig = await loadSandboxConfig(settings.merged, argv);
// We intentionally omit the list of extensions here because extensions
// should not impact auth or setting up the sandbox.
// TODO(jacobr): refactor loadCliConfig so there is a minimal version
// that only initializes enough config to enable refreshAuth or find
// another way to decouple refreshAuth from requiring a config.
if (sandboxConfig) {
if (initialAuthFailed) {
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
let stdinData = '';
if (!process.stdin.isTTY) {
stdinData = await readStdin();
}
// This function is a copy of the one from sandbox.ts
// It is moved here to decouple sandbox.ts from the CLI's argument structure.
const injectStdinIntoArgs = (
args: string[],
stdinData?: string,
): string[] => {
const finalArgs = [...args];
if (stdinData) {
const promptIndex = finalArgs.findIndex(
(arg) => arg === '--prompt' || arg === '-p',
);
if (promptIndex > -1 && finalArgs.length > promptIndex + 1) {
// If there's a prompt argument, prepend stdin to it
finalArgs[promptIndex + 1] =
`${stdinData}\n\n${finalArgs[promptIndex + 1]}`;
} else {
// If there's no prompt argument, add stdin as the prompt
finalArgs.push('--prompt', stdinData);
}
}
return finalArgs;
};
const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData);
await relaunchOnExitCode(() =>
start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs),
);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
} else {
// Relaunch app so we always have a child process that can be internally
// restarted if needed.
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
}
if (initialAuthFailed) {
await runExitCleanup();
process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR);
}
// We are now past the logic handling potentially launching a child process
@@ -6,15 +6,24 @@
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
SerializableConfirmationDetails,
ToolCallConfirmationDetails,
Config,
import {
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
hasRedirection,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
hasRedirection: vi.fn(),
};
});
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
const actual =
await importOriginal<
@@ -240,6 +249,90 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
it('should display redirection warning when hasRedirection is true in confirmationDetails', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Redirection',
command: 'ls > out.txt',
rootCommand: 'ls',
rootCommands: ['ls'],
hasRedirection: true,
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Note: Command contains redirection which can be undesirable.');
unmount();
});
it('should display redirection warning via async fallback when hasRedirection is missing', async () => {
vi.mocked(hasRedirection).mockReturnValue(true);
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Redirection Fallback',
command: 'ls > out.txt',
rootCommand: 'ls',
rootCommands: ['ls'],
// hasRedirection is missing
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
// The warning should be present as it is now calculated synchronously
const output = lastFrame();
expect(output).toContain('Note: Command contains redirection which can be undesirable.');
unmount();
});
it('should NOT display redirection warning when hasRedirection is false', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm No Redirection',
command: 'ls -la',
rootCommand: 'ls',
rootCommands: ['ls'],
hasRedirection: false,
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Note: This command uses shell redirection');
unmount();
});
it('should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot)', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
@@ -90,6 +90,21 @@ export const ToolConfirmationMessage: React.FC<
confirmationDetails.type === 'exit_plan_mode';
const isTrustedFolder = config.isTrustedFolder();
const commandsToDisplay =
confirmationDetails.type === 'exec' &&
confirmationDetails.commands &&
confirmationDetails.commands.length > 1
? confirmationDetails.commands
: confirmationDetails.type === 'exec'
? [confirmationDetails.command]
: [];
const hasRedirectionCheck =
confirmationDetails.type === 'exec'
? confirmationDetails.hasRedirection ??
commandsToDisplay.some((cmd) => hasRedirection(cmd))
: false;
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
void confirm(callId, outcome, payload).catch((error: unknown) => {
@@ -484,17 +499,14 @@ export const ToolConfirmationMessage: React.FC<
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
const containsRedirection = hasRedirectionCheck;
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode | null = null;
const commandsToDisplay =
executionProps.commands && executionProps.commands.length > 1
? executionProps.commands
: [executionProps.command];
const containsRedirection = commandsToDisplay.some((cmd) =>
hasRedirection(cmd),
);
let bodyContentHeight = availableBodyContentHeight();
let warnings: React.ReactNode = null;
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="173" viewBox="0 0 920 173">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="173" fill="#000000" />
<rect width="920" height="224" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
<text x="45" y="2" fill="#cdcd00" textLength="63" lengthAdjust="spacingAndGlyphs">&quot;hello&quot;</text>
@@ -14,17 +14,21 @@
<text x="18" y="36" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">echo</text>
<text x="63" y="36" fill="#cd00cd" textLength="18" lengthAdjust="spacingAndGlyphs">$i</text>
<text x="0" y="53" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">done</text>
<text x="0" y="70" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of: &apos;echo&apos;? </text>
<rect x="0" y="102" width="9" height="17" fill="#001a00" />
<text x="0" y="104" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="102" width="9" height="17" fill="#001a00" />
<rect x="18" y="102" width="18" height="17" fill="#001a00" />
<text x="18" y="104" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="36" y="102" width="9" height="17" fill="#001a00" />
<rect x="45" y="102" width="90" height="17" fill="#001a00" />
<text x="45" y="104" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="135" y="102" width="135" height="17" fill="#001a00" />
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
<text x="0" y="138" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 3. No, suggest changes (esc) </text>
<text x="0" y="87" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Note: </text>
<text x="54" y="87" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs">Command contains redirection which can be undesirable. </text>
<text x="0" y="104" fill="#333333" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">Tip: </text>
<text x="54" y="104" fill="#333333" textLength="576" lengthAdjust="spacingAndGlyphs">Toggle auto-edit (Shift+Tab) to allow redirection in the future.</text>
<text x="0" y="121" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of: &apos;echo&apos;? </text>
<rect x="0" y="153" width="9" height="17" fill="#001a00" />
<text x="0" y="155" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="153" width="9" height="17" fill="#001a00" />
<rect x="18" y="153" width="18" height="17" fill="#001a00" />
<text x="18" y="155" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="36" y="153" width="9" height="17" fill="#001a00" />
<rect x="45" y="153" width="90" height="17" fill="#001a00" />
<text x="45" y="155" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="135" y="153" width="135" height="17" fill="#001a00" />
<text x="0" y="172" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 2. Allow for this session </text>
<text x="0" y="189" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> 3. No, suggest changes (esc) </text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -42,6 +42,9 @@ exports[`ToolConfirmationMessage > should render multiline shell scripts with co
for i in 1 2 3; do
echo $i
done
Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo'?
● 1. Allow once
@@ -93,6 +96,9 @@ Apply this change?
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"echo "hello"
Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo'?
● 1. Allow once
@@ -102,6 +108,9 @@ Allow execution of: 'echo'?
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should show "allow always" when folder is trusted 1`] = `
"echo "hello"
Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo'?
● 1. Allow once
@@ -76,6 +76,7 @@ import {
type ShellExecutionResult,
type ShellOutputEvent,
CoreToolCallStatus,
SandboxProfile,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as os from 'node:os';
@@ -214,6 +215,7 @@ describe('useShellCommandProcessor', () => {
expect.any(Object),
false,
expect.any(Object),
SandboxProfile.WORKSPACE_WRITE,
);
expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
});
@@ -306,6 +308,7 @@ describe('useShellCommandProcessor', () => {
expect.any(Object),
false, // enableInteractiveShell
expect.any(Object),
SandboxProfile.WORKSPACE_WRITE,
);
// Wait for the async PID update to happen.
@@ -430,6 +433,7 @@ describe('useShellCommandProcessor', () => {
expect.any(Object),
false,
expect.any(Object),
SandboxProfile.WORKSPACE_WRITE,
);
await act(async () => {
@@ -65,7 +65,7 @@ describe('useTurnActivityMonitor', () => {
expect(result.current.operationStartTime).toBe(2000);
});
it('should detect redirection from tool calls', () => {
it('should detect redirection from tool calls', async () => {
// Force mock implementation to ensure it's active
vi.mocked(hasRedirection).mockImplementation((q: string) =>
q.includes('>'),
@@ -48,19 +48,14 @@ export const useTurnActivityMonitor = (
}, [streamingState, activePtyId]);
// Detect redirection in the current query or tool calls.
// We derive this directly during render to ensure it's accurate from the first frame.
const isRedirectionActive = useMemo(
() =>
// Check active tool calls for run_shell_command
pendingToolCalls.some((tc) => {
if (tc.request.name !== 'run_shell_command') return false;
const isRedirectionActive = useMemo(() => {
return pendingToolCalls.some((tc) => {
if (tc.request.name !== 'run_shell_command') return false;
const command =
(tc.request.args as { command?: string })?.command || '';
return hasRedirection(command);
}),
[pendingToolCalls],
);
const command = (tc.request.args as { command?: string })?.command || '';
return hasRedirection(command);
});
}, [pendingToolCalls]);
return {
operationStartTime,
+13 -13
View File
@@ -137,7 +137,7 @@ describe('sandbox', () => {
describe('start_sandbox', () => {
it('should handle macOS seatbelt (sandbox-exec)', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'sandbox-exec',
image: 'some-image',
};
@@ -173,7 +173,7 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(fs.existsSync).mockReturnValue(false);
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'sandbox-exec',
image: 'some-image',
};
@@ -182,7 +182,7 @@ describe('sandbox', () => {
});
it('should handle Docker execution', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -231,7 +231,7 @@ describe('sandbox', () => {
});
it('should pull image if missing', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'missing-image',
};
@@ -300,7 +300,7 @@ describe('sandbox', () => {
});
it('should throw if image pull fails', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'missing-image',
};
@@ -338,7 +338,7 @@ describe('sandbox', () => {
});
it('should mount volumes correctly', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -395,7 +395,7 @@ describe('sandbox', () => {
});
it('should pass through GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -442,7 +442,7 @@ describe('sandbox', () => {
});
it('should handle user creation on Linux if needed', async () => {
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'docker',
image: 'gemini-cli-sandbox',
};
@@ -508,7 +508,7 @@ describe('sandbox', () => {
it('should run lxc exec with correct args for a running container', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_RUNNING;
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'lxc',
image: 'gemini-sandbox',
};
@@ -542,7 +542,7 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if lxc list fails', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = 'throw';
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'lxc',
image: 'gemini-sandbox',
};
@@ -554,7 +554,7 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if container is not running', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = LXC_STOPPED;
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'lxc',
image: 'gemini-sandbox',
};
@@ -564,7 +564,7 @@ describe('sandbox', () => {
it('should throw FatalSandboxError if container is not found in list', async () => {
process.env['TEST_LXC_LIST_OUTPUT'] = '[]';
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'lxc',
image: 'gemini-sandbox',
};
@@ -577,7 +577,7 @@ describe('sandbox', () => {
describe('gVisor (runsc)', () => {
it('should use docker with --runtime=runsc on Linux', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
const config: SandboxConfig = {
const config: SandboxConfig = { enabled: true,
command: 'runsc',
image: 'gemini-cli-sandbox',
};
+13 -2
View File
@@ -268,6 +268,13 @@ export async function start_sandbox(
}
}
// stop if command or image is missing
if (!command || !image) {
throw new FatalSandboxError(
'Sandbox configuration is missing required "command" or "image" field.',
);
}
// stop if image is missing
if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) {
const remedy =
@@ -714,10 +721,10 @@ export async function start_sandbox(
// proxyProcess.stdout?.on('data', (data) => {
// console.info(data.toString());
// });
proxyProcess.stderr?.on('data', (data) => {
proxyProcess?.stderr?.on('data', (data) => {
debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
});
proxyProcess.on('close', (code, signal) => {
proxyProcess?.on('close', (code, signal) => {
if (sandboxProcess?.pid) {
process.kill(-sandboxProcess.pid, 'SIGTERM');
}
@@ -743,6 +750,10 @@ export async function start_sandbox(
});
return await new Promise<number>((resolve, reject) => {
if (!sandboxProcess) {
reject(new Error('Failed to spawn sandbox process.'));
return;
}
sandboxProcess.on('error', (err) => {
coreEvents.emitFeedback('error', 'Sandbox process error', err);
reject(err);