diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 1d0c511f93c..046b100a12b 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -20,18 +20,27 @@ import { import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; import { EventEmitter } from 'node:events'; -const { mockedHomedir, mockedGetContainerPath, mockedExecCommands } = - vi.hoisted(() => ({ - mockedHomedir: vi.fn().mockReturnValue('/home/user'), - mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p), - mockedExecCommands: [] as string[], - })); +const { + mockedHomedir, + mockedGetContainerPath, + mockedExecCommands, + mockedPersistSandboxState, + mockedRestoreSandboxState, +} = vi.hoisted(() => ({ + mockedHomedir: vi.fn().mockReturnValue('/home/user'), + mockedGetContainerPath: vi.fn().mockImplementation((p: string) => p), + mockedExecCommands: [] as string[], + mockedPersistSandboxState: vi.fn(), + mockedRestoreSandboxState: vi.fn(), +})); vi.mock('./sandboxUtils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getContainerPath: mockedGetContainerPath, + persistSandboxState: mockedPersistSandboxState, + restoreSandboxState: mockedRestoreSandboxState, }; }); @@ -1072,6 +1081,14 @@ describe('sandbox', () => { expect.stringContaining('gemini-sandbox-'), 0o700, ); + expect(mockedRestoreSandboxState).toHaveBeenCalledWith( + '/home/user/.gemini/sandbox', + expect.stringContaining('gemini-sandbox-'), + ); + expect(mockedPersistSandboxState).toHaveBeenCalledWith( + expect.stringContaining('gemini-sandbox-'), + '/home/user/.gemini/sandbox', + ); }); it('should tolerate chmod errors on non-POSIX filesystems without crashing', async () => { diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 007a4645080..7bf68ce65d8 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -42,6 +42,8 @@ import { BUILTIN_SEATBELT_PROFILES, isSensitiveHostPath, sanitizeSettingsForSandbox, + persistSandboxState, + restoreSandboxState, } from './sandboxUtils.js'; import { BUILTIN_SEATBELT_PROFILE_CONTENTS } from './sandboxBuiltinProfiles.js'; @@ -67,11 +69,25 @@ export async function start_sandbox( let stopProxy: (() => void) | undefined = undefined; let tempProfileFile: string | null = null; let sandboxTmpDir: string | null = null; + let sandboxStateDir: string | null = null; const cleanup = () => { if (sandboxTmpDir) { const dirToDelete = sandboxTmpDir; + const stateDir = sandboxStateDir; sandboxTmpDir = null; + sandboxStateDir = null; + + if (stateDir && fs.existsSync(dirToDelete)) { + try { + persistSandboxState(dirToDelete, stateDir); + } catch (err) { + debugLogger.warn( + `Failed to persist sandbox state: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + try { if (fs.existsSync(dirToDelete)) { fs.rmSync(dirToDelete, { recursive: true, force: true }); @@ -524,9 +540,9 @@ export async function start_sandbox( } } - // Sanitize user settings before mounting into the sandbox container. - // We STRICTLY do NOT mount ~/.gemini root directory or sensitive credential files - // (oauth_creds.json, .env, etc.). We only mount the sanitized settings file as read-only (:ro). + // Sanitize user settings before mounting into the sandbox container. We do + // not expose the user's normal ~/.gemini credentials. State created by the + // sandbox is restored from a separate, sandbox-only directory instead. const userHomeDirOnHost = homedir(); let rawSettings: Record = {}; @@ -560,6 +576,11 @@ export async function start_sandbox( { mode: 0o600 }, ); + if (userHomeDirOnHost) { + sandboxStateDir = path.join(userHomeDirOnHost, GEMINI_DIR, 'sandbox'); + restoreSandboxState(sandboxStateDir, sandboxTmpDir); + } + // Mount isolated sanitized settings directory inside container const userSettingsDirInSandbox = getContainerPath( `/home/node/${GEMINI_DIR}`, diff --git a/packages/cli/src/utils/sandboxUtils.test.ts b/packages/cli/src/utils/sandboxUtils.test.ts index 0819d79f6fe..58a99eb13aa 100644 --- a/packages/cli/src/utils/sandboxUtils.test.ts +++ b/packages/cli/src/utils/sandboxUtils.test.ts @@ -21,6 +21,9 @@ import { isCredentialOrSensitivePath, prepareIsolatedSettingsDir, SENSITIVE_SETTINGS_FILENAMES, + SANDBOX_PERSISTED_STATE_ENTRIES, + persistSandboxState, + restoreSandboxState, } from './sandboxUtils.js'; vi.mock('node:os'); @@ -110,6 +113,8 @@ describe('sandboxUtils', () => { beforeEach(() => { vi.mocked(os.platform).mockReturnValue('linux'); vi.mocked(fs.existsSync).mockReturnValue(false); + vi.stubEnv('PATH', ''); + vi.stubEnv('PYTHONPATH', ''); }); it('should generate default entrypoint', () => { @@ -762,4 +767,140 @@ describe('sandboxUtils', () => { expect(fs.cpSync).not.toHaveBeenCalled(); }); }); + + describe('sandbox state persistence', () => { + const sandboxSettingsDir = '/tmp/gemini-sandbox-xyz'; + const sandboxStateDir = '/home/user/.gemini/sandbox'; + + it('should persist only explicitly approved sandbox state', () => { + vi.mocked(fs.existsSync).mockImplementation((target) => { + const value = String(target); + return ( + value === path.join(sandboxSettingsDir, 'oauth_creds.json') || + value === path.join(sandboxSettingsDir, 'trustedFolders.json') || + value === path.join(sandboxSettingsDir, 'tmp') || + value === path.join(sandboxSettingsDir, 'settings.json') + ); + }); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + security: { + auth: { + selectedType: 'oauth-personal', + useExternal: true, + enforcedType: 'should-not-persist', + }, + }, + hooks: { BeforeAgent: [{ command: 'unsafe' }] }, + }), + ); + + persistSandboxState(sandboxSettingsDir, sandboxStateDir); + + expect(fs.mkdirSync).toHaveBeenCalledWith(sandboxStateDir, { + recursive: true, + mode: 0o700, + }); + expect(fs.chmodSync).toHaveBeenCalledWith(sandboxStateDir, 0o700); + expect(fs.cpSync).toHaveBeenCalledWith( + path.join(sandboxSettingsDir, 'oauth_creds.json'), + path.join(sandboxStateDir, 'oauth_creds.json'), + { recursive: true, force: true }, + ); + expect(fs.cpSync).toHaveBeenCalledWith( + path.join(sandboxSettingsDir, 'trustedFolders.json'), + path.join(sandboxStateDir, 'trustedFolders.json'), + { recursive: true, force: true }, + ); + expect(fs.cpSync).toHaveBeenCalledWith( + path.join(sandboxSettingsDir, 'tmp'), + path.join(sandboxStateDir, 'tmp'), + { recursive: true, force: true }, + ); + expect(fs.cpSync).not.toHaveBeenCalledWith( + path.join(sandboxSettingsDir, 'settings.json'), + expect.anything(), + expect.anything(), + ); + + const authSettingsWrite = vi + .mocked(fs.writeFileSync) + .mock.calls.find( + ([target]) => + String(target) === path.join(sandboxStateDir, 'auth-settings.json'), + ); + expect(authSettingsWrite).toBeDefined(); + expect(JSON.parse(String(authSettingsWrite?.[1]))).toEqual({ + selectedType: 'oauth-personal', + useExternal: true, + }); + }); + + it('should restore persisted state and merge only authentication settings', () => { + vi.mocked(fs.existsSync).mockImplementation((target) => { + const value = String(target); + return ( + value === sandboxStateDir || + value === path.join(sandboxStateDir, 'oauth_creds.json') || + value === path.join(sandboxStateDir, 'trustedFolders.json') || + value === path.join(sandboxStateDir, 'tmp') || + value === path.join(sandboxStateDir, 'auth-settings.json') || + value === path.join(sandboxSettingsDir, 'settings.json') + ); + }); + vi.mocked(fs.readFileSync).mockImplementation((target) => { + if (String(target).endsWith('auth-settings.json')) { + return JSON.stringify({ + selectedType: 'oauth-personal', + useExternal: true, + hooks: 'ignored', + }); + } + return JSON.stringify({ + theme: 'dark', + security: { auth: { enforcedType: 'oauth-personal' } }, + }); + }); + + restoreSandboxState(sandboxStateDir, sandboxSettingsDir); + + for (const entry of ['oauth_creds.json', 'trustedFolders.json', 'tmp']) { + expect(fs.cpSync).toHaveBeenCalledWith( + path.join(sandboxStateDir, entry), + path.join(sandboxSettingsDir, entry), + { recursive: true, force: true }, + ); + } + + const settingsWrite = vi + .mocked(fs.writeFileSync) + .mock.calls.find( + ([target]) => + String(target) === path.join(sandboxSettingsDir, 'settings.json'), + ); + expect(settingsWrite).toBeDefined(); + expect(JSON.parse(String(settingsWrite?.[1]))).toEqual({ + theme: 'dark', + security: { + auth: { + enforcedType: 'oauth-personal', + selectedType: 'oauth-personal', + useExternal: true, + }, + }, + }); + }); + + it('should include authentication, trust, and session paths in the allowlist', () => { + expect(SANDBOX_PERSISTED_STATE_ENTRIES).toEqual( + expect.arrayContaining([ + 'oauth_creds.json', + 'google_accounts.json', + 'trustedFolders.json', + 'history', + 'tmp', + ]), + ); + }); + }); }); diff --git a/packages/cli/src/utils/sandboxUtils.ts b/packages/cli/src/utils/sandboxUtils.ts index 26c5562f3f2..9d5e441fece 100644 --- a/packages/cli/src/utils/sandboxUtils.ts +++ b/packages/cli/src/utils/sandboxUtils.ts @@ -30,10 +30,44 @@ export const BUILTIN_SEATBELT_PROFILES = [ 'strict-proxied', ]; +/** + * State created by Gemini CLI inside a container that may safely persist + * between sandbox invocations. This state is kept separate from the user's + * normal ~/.gemini data so the sandbox never receives host credentials. + */ +export const SANDBOX_PERSISTED_STATE_ENTRIES = [ + 'oauth_creds.json', + 'google_accounts.json', + 'gemini-credentials.json', + 'mcp-oauth-tokens.json', + 'a2a-oauth-tokens.json', + 'trustedFolders.json', + 'history', + 'tmp', +] as const; + +const SANDBOX_AUTH_SETTINGS_FILENAME = 'auth-settings.json'; + +interface SandboxAuthSettings { + selectedType?: string; + useExternal?: boolean; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isSandboxAuthSettings(value: unknown): value is SandboxAuthSettings { + if (!isRecord(value)) return false; + + const selectedType = value['selectedType']; + const useExternal = value['useExternal']; + return ( + (selectedType === undefined || typeof selectedType === 'string') && + (useExternal === undefined || typeof useExternal === 'boolean') + ); +} + /** * Known sensitive or credential file names that must not be mounted into the sandbox container. */ @@ -267,6 +301,177 @@ export function sanitizeSettingsForSandbox( return sanitized; } +function copySandboxStateEntries( + sourceDir: string, + destinationDir: string, + removeMissing: boolean, +): void { + for (const entry of SANDBOX_PERSISTED_STATE_ENTRIES) { + const source = path.join(sourceDir, entry); + const destination = path.join(destinationDir, entry); + + try { + if (!fs.existsSync(source)) { + if (removeMissing && fs.existsSync(destination)) { + fs.rmSync(destination, { recursive: true, force: true }); + } + continue; + } + + if (fs.existsSync(destination)) { + fs.rmSync(destination, { recursive: true, force: true }); + } + fs.cpSync(source, destination, { recursive: true, force: true }); + } catch (err) { + debugLogger.warn( + `Failed to persist sandbox state entry '${entry}': ${err instanceof Error ? err.message : String(err)}`, + ); + } + } +} + +function getSandboxAuthSettings( + settings: Record, +): SandboxAuthSettings | undefined { + const security = settings['security']; + if (!isRecord(security)) return undefined; + + const auth = security['auth']; + if (!isSandboxAuthSettings(auth)) return undefined; + + const persistedAuth: SandboxAuthSettings = {}; + if (auth.selectedType !== undefined) { + persistedAuth.selectedType = auth.selectedType; + } + if (auth.useExternal !== undefined) { + persistedAuth.useExternal = auth.useExternal; + } + + return Object.keys(persistedAuth).length > 0 ? persistedAuth : undefined; +} + +function persistSandboxAuthSettings( + sandboxSettingsDir: string, + sandboxStateDir: string, +): void { + const settingsFile = path.join(sandboxSettingsDir, 'settings.json'); + const authSettingsFile = path.join( + sandboxStateDir, + SANDBOX_AUTH_SETTINGS_FILENAME, + ); + + if (!fs.existsSync(settingsFile)) return; + + try { + const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf8')) as unknown; + if (!isRecord(parsed)) return; + + const authSettings = getSandboxAuthSettings(parsed); + if (!authSettings) { + if (fs.existsSync(authSettingsFile)) { + fs.rmSync(authSettingsFile, { force: true }); + } + return; + } + + fs.writeFileSync(authSettingsFile, JSON.stringify(authSettings, null, 2), { + mode: 0o600, + }); + } catch (err) { + debugLogger.warn( + `Failed to persist sandbox authentication settings: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +function restoreSandboxAuthSettings( + sandboxStateDir: string, + sandboxSettingsDir: string, +): void { + const authSettingsFile = path.join( + sandboxStateDir, + SANDBOX_AUTH_SETTINGS_FILENAME, + ); + const settingsFile = path.join(sandboxSettingsDir, 'settings.json'); + + if (!fs.existsSync(authSettingsFile) || !fs.existsSync(settingsFile)) return; + + try { + const persistedAuth = JSON.parse( + fs.readFileSync(authSettingsFile, 'utf8'), + ) as unknown; + const parsedSettings = JSON.parse( + fs.readFileSync(settingsFile, 'utf8'), + ) as unknown; + if (!isSandboxAuthSettings(persistedAuth) || !isRecord(parsedSettings)) { + return; + } + + const security = isRecord(parsedSettings['security']) + ? { ...parsedSettings['security'] } + : {}; + const auth = isRecord(security['auth']) ? { ...security['auth'] } : {}; + + if (persistedAuth.selectedType !== undefined) { + auth['selectedType'] = persistedAuth.selectedType; + } + if (persistedAuth.useExternal !== undefined) { + auth['useExternal'] = persistedAuth.useExternal; + } + + security['auth'] = auth; + parsedSettings['security'] = security; + fs.writeFileSync(settingsFile, JSON.stringify(parsedSettings, null, 2), { + mode: 0o600, + }); + } catch (err) { + debugLogger.warn( + `Failed to restore sandbox authentication settings: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Restores state created by an earlier sandbox invocation into the ephemeral + * settings directory used by the next container. + */ +export function restoreSandboxState( + sandboxStateDir: string, + sandboxSettingsDir: string, +): void { + if (!fs.existsSync(sandboxStateDir)) return; + + copySandboxStateEntries(sandboxStateDir, sandboxSettingsDir, false); + restoreSandboxAuthSettings(sandboxStateDir, sandboxSettingsDir); +} + +/** + * Saves only explicitly approved sandbox-owned state before the ephemeral + * settings directory is removed. Host ~/.gemini credentials are never read or + * copied into this directory. + */ +export function persistSandboxState( + sandboxSettingsDir: string, + sandboxStateDir: string, +): void { + try { + fs.mkdirSync(sandboxStateDir, { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(sandboxStateDir, 0o700); + } catch { + // Ignore permission errors on non-POSIX filesystems. + } + } catch (err) { + debugLogger.warn( + `Failed to prepare persistent sandbox state directory: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + + copySandboxStateEntries(sandboxSettingsDir, sandboxStateDir, true); + persistSandboxAuthSettings(sandboxSettingsDir, sandboxStateDir); +} + /** * Creates an isolated settings directory in a temporary location, populated with non-sensitive * configuration files from the user settings directory while excluding credential and auth files.