mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first (#25427)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
@@ -8,8 +8,13 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|||||||
import { spawn, exec, execFile, execSync } from 'node:child_process';
|
import { spawn, exec, execFile, execSync } from 'node:child_process';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import { start_sandbox } from './sandbox.js';
|
import { start_sandbox } from './sandbox.js';
|
||||||
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
|
import {
|
||||||
|
FatalSandboxError,
|
||||||
|
homedir,
|
||||||
|
type SandboxConfig,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
|
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
@@ -133,6 +138,7 @@ describe('sandbox', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
process.argv = originalArgv;
|
process.argv = originalArgv;
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('start_sandbox', () => {
|
describe('start_sandbox', () => {
|
||||||
@@ -171,6 +177,105 @@ describe('sandbox', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should resolve custom seatbelt profile from user home directory', async () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||||
|
vi.stubEnv('SEATBELT_PROFILE', 'custom-test');
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||||
|
String(p).includes(
|
||||||
|
path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const config: SandboxConfig = createMockSandboxConfig({
|
||||||
|
command: 'sandbox-exec',
|
||||||
|
image: 'some-image',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MockProcess extends EventEmitter {
|
||||||
|
stdout: EventEmitter;
|
||||||
|
stderr: EventEmitter;
|
||||||
|
}
|
||||||
|
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||||
|
mockSpawnProcess.stdout = new EventEmitter();
|
||||||
|
mockSpawnProcess.stderr = new EventEmitter();
|
||||||
|
vi.mocked(spawn).mockReturnValue(
|
||||||
|
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
mockSpawnProcess.emit('close', 0);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBe(0);
|
||||||
|
expect(spawn).toHaveBeenCalledWith(
|
||||||
|
'sandbox-exec',
|
||||||
|
expect.any(Array),
|
||||||
|
expect.objectContaining({ stdio: 'inherit' }),
|
||||||
|
);
|
||||||
|
const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1];
|
||||||
|
expect(spawnArgs).toEqual(
|
||||||
|
expect.arrayContaining(['-f', expect.any(String)]),
|
||||||
|
);
|
||||||
|
const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1];
|
||||||
|
expect(profileArg).toEqual(
|
||||||
|
expect.stringContaining(
|
||||||
|
path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to project .gemini directory when user profile is missing', async () => {
|
||||||
|
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||||
|
vi.stubEnv('SEATBELT_PROFILE', 'custom-test');
|
||||||
|
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||||
|
const s = String(p);
|
||||||
|
return (
|
||||||
|
s.includes(path.join('.gemini', 'sandbox-macos-custom-test.sb')) &&
|
||||||
|
!s.includes(path.join(homedir(), '.gemini'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const config: SandboxConfig = createMockSandboxConfig({
|
||||||
|
command: 'sandbox-exec',
|
||||||
|
image: 'some-image',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MockProcess extends EventEmitter {
|
||||||
|
stdout: EventEmitter;
|
||||||
|
stderr: EventEmitter;
|
||||||
|
}
|
||||||
|
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||||
|
mockSpawnProcess.stdout = new EventEmitter();
|
||||||
|
mockSpawnProcess.stderr = new EventEmitter();
|
||||||
|
vi.mocked(spawn).mockReturnValue(
|
||||||
|
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
mockSpawnProcess.emit('close', 0);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
await expect(promise).resolves.toBe(0);
|
||||||
|
expect(spawn).toHaveBeenCalledWith(
|
||||||
|
'sandbox-exec',
|
||||||
|
expect.any(Array),
|
||||||
|
expect.objectContaining({ stdio: 'inherit' }),
|
||||||
|
);
|
||||||
|
const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1];
|
||||||
|
expect(spawnArgs).toEqual(
|
||||||
|
expect.arrayContaining(['-f', expect.any(String)]),
|
||||||
|
);
|
||||||
|
const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1];
|
||||||
|
expect(profileArg).toEqual(
|
||||||
|
expect.stringContaining(
|
||||||
|
path.join('.gemini', 'sandbox-macos-custom-test.sb'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(profileArg).not.toContain(homedir());
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
|
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
|
||||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|||||||
@@ -68,9 +68,17 @@ export async function start_sandbox(
|
|||||||
let profileFile = fileURLToPath(
|
let profileFile = fileURLToPath(
|
||||||
new URL(`sandbox-macos-${profile}.sb`, import.meta.url),
|
new URL(`sandbox-macos-${profile}.sb`, import.meta.url),
|
||||||
);
|
);
|
||||||
// if profile name is not recognized, then look for file under project settings directory
|
// if profile name is not recognized, look in user-level ~/.gemini first,
|
||||||
|
// then fall back to project-level .gemini. path.basename() strips any
|
||||||
|
// directory separators to prevent path traversal via SEATBELT_PROFILE.
|
||||||
if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) {
|
if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) {
|
||||||
profileFile = path.join(GEMINI_DIR, `sandbox-macos-${profile}.sb`);
|
const safeProfile = path.basename(profile);
|
||||||
|
const fileName = `sandbox-macos-${safeProfile}.sb`;
|
||||||
|
const userProfileFile = path.join(homedir(), GEMINI_DIR, fileName);
|
||||||
|
const projectProfileFile = path.join(GEMINI_DIR, fileName);
|
||||||
|
profileFile = fs.existsSync(userProfileFile)
|
||||||
|
? userProfileFile
|
||||||
|
: projectProfileFile;
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(profileFile)) {
|
if (!fs.existsSync(profileFile)) {
|
||||||
throw new FatalSandboxError(
|
throw new FatalSandboxError(
|
||||||
|
|||||||
Reference in New Issue
Block a user