mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-12 12:26:57 -07:00
975b7dc163
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
225 lines
7.1 KiB
TypeScript
225 lines
7.1 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { MacOsSandboxManager } from './MacOsSandboxManager.js';
|
|
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
describe('MacOsSandboxManager', () => {
|
|
let mockWorkspace: string;
|
|
let mockAllowedPaths: string[];
|
|
const mockNetworkAccess = true;
|
|
|
|
let mockPolicy: ExecutionPolicy;
|
|
let manager: MacOsSandboxManager;
|
|
|
|
beforeEach(() => {
|
|
mockWorkspace = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'gemini-cli-macos-test-'),
|
|
);
|
|
mockAllowedPaths = [
|
|
path.join(os.tmpdir(), 'gemini-cli-macos-test-allowed'),
|
|
];
|
|
if (!fs.existsSync(mockAllowedPaths[0])) {
|
|
fs.mkdirSync(mockAllowedPaths[0]);
|
|
}
|
|
|
|
mockPolicy = {
|
|
allowedPaths: mockAllowedPaths,
|
|
networkAccess: mockNetworkAccess,
|
|
};
|
|
|
|
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
|
|
// Mock realpathSync to just return the path for testing
|
|
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
fs.rmSync(mockWorkspace, { recursive: true, force: true });
|
|
if (mockAllowedPaths && mockAllowedPaths[0]) {
|
|
fs.rmSync(mockAllowedPaths[0], { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('prepareCommand', () => {
|
|
it('should build a strict allowlist profile allowing the workspace via param', async () => {
|
|
const result = await manager.prepareCommand({
|
|
command: 'echo',
|
|
args: ['hello'],
|
|
cwd: mockWorkspace,
|
|
env: {},
|
|
policy: { networkAccess: false },
|
|
});
|
|
|
|
expect(result.program).toBe('/usr/bin/sandbox-exec');
|
|
const profile = result.args[1];
|
|
expect(profile).toContain('(version 1)');
|
|
expect(profile).toContain('(deny default)');
|
|
expect(profile).toContain('(allow process-exec)');
|
|
expect(profile).toContain('(subpath (param "WORKSPACE"))');
|
|
expect(profile).not.toContain('(allow network-outbound)');
|
|
|
|
expect(result.args).toContain('-D');
|
|
expect(result.args).toContain(`WORKSPACE=${mockWorkspace}`);
|
|
expect(result.args).toContain(`TMPDIR=${os.tmpdir()}`);
|
|
|
|
// Governance files should be protected
|
|
expect(profile).toContain(
|
|
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
|
|
); // .gitignore
|
|
expect(profile).toContain(
|
|
'(deny file-write* (literal (param "GOVERNANCE_FILE_1")))',
|
|
); // .geminiignore
|
|
expect(profile).toContain(
|
|
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
|
|
); // .git
|
|
});
|
|
|
|
it('should allow network when networkAccess is true in policy', async () => {
|
|
const result = await manager.prepareCommand({
|
|
command: 'curl',
|
|
args: ['example.com'],
|
|
cwd: mockWorkspace,
|
|
env: {},
|
|
policy: { networkAccess: true },
|
|
});
|
|
|
|
const profile = result.args[1];
|
|
expect(profile).toContain('(allow network-outbound)');
|
|
});
|
|
|
|
it('should parameterize allowed paths and normalize them', async () => {
|
|
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
|
|
if (p === '/test/symlink') return '/test/real_path';
|
|
return p as string;
|
|
});
|
|
|
|
const result = await manager.prepareCommand({
|
|
command: 'ls',
|
|
args: ['/custom/path1'],
|
|
cwd: mockWorkspace,
|
|
env: {},
|
|
policy: {
|
|
allowedPaths: ['/custom/path1', '/test/symlink'],
|
|
},
|
|
});
|
|
|
|
const profile = result.args[1];
|
|
expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))');
|
|
expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))');
|
|
|
|
expect(result.args).toContain('-D');
|
|
expect(result.args).toContain('ALLOWED_PATH_0=/custom/path1');
|
|
expect(result.args).toContain('ALLOWED_PATH_1=/test/real_path');
|
|
});
|
|
|
|
it('should format the executable and arguments correctly for sandbox-exec', async () => {
|
|
const result = await manager.prepareCommand({
|
|
command: 'echo',
|
|
args: ['hello'],
|
|
cwd: mockWorkspace,
|
|
env: {},
|
|
policy: mockPolicy,
|
|
});
|
|
|
|
expect(result.program).toBe('/usr/bin/sandbox-exec');
|
|
expect(result.args.slice(-3)).toEqual(['--', 'echo', 'hello']);
|
|
});
|
|
|
|
it('should correctly pass through the cwd to the resulting command', async () => {
|
|
const result = await manager.prepareCommand({
|
|
command: 'echo',
|
|
args: ['hello'],
|
|
cwd: '/test/different/cwd',
|
|
env: {},
|
|
policy: mockPolicy,
|
|
});
|
|
|
|
expect(result.cwd).toBe('/test/different/cwd');
|
|
});
|
|
|
|
it('should apply environment sanitization via the default mechanisms', async () => {
|
|
const result = await manager.prepareCommand({
|
|
command: 'echo',
|
|
args: ['hello'],
|
|
cwd: mockWorkspace,
|
|
env: {
|
|
SAFE_VAR: '1',
|
|
GITHUB_TOKEN: 'sensitive',
|
|
},
|
|
policy: {
|
|
...mockPolicy,
|
|
sanitizationConfig: { enableEnvironmentVariableRedaction: true },
|
|
},
|
|
});
|
|
|
|
expect(result.env['SAFE_VAR']).toBe('1');
|
|
expect(result.env['GITHUB_TOKEN']).toBeUndefined();
|
|
});
|
|
|
|
it('should resolve parent directories if a file does not exist', async () => {
|
|
const baseTmpDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'gemini-cli-macos-realpath-test-'),
|
|
);
|
|
const realPath = path.join(baseTmpDir, 'real_path');
|
|
const nonexistentFile = path.join(realPath, 'nonexistent.txt');
|
|
|
|
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
|
|
if (p === nonexistentFile) {
|
|
const error = new Error('ENOENT');
|
|
Object.assign(error, { code: 'ENOENT' });
|
|
throw error;
|
|
}
|
|
if (p === realPath) {
|
|
return path.join(baseTmpDir, 'resolved_path');
|
|
}
|
|
return p as string;
|
|
});
|
|
|
|
try {
|
|
const dynamicManager = new MacOsSandboxManager({
|
|
workspace: nonexistentFile,
|
|
});
|
|
const dynamicResult = await dynamicManager.prepareCommand({
|
|
command: 'echo',
|
|
args: ['hello'],
|
|
cwd: nonexistentFile,
|
|
env: {},
|
|
});
|
|
|
|
expect(dynamicResult.args).toContain(
|
|
`WORKSPACE=${path.join(baseTmpDir, 'resolved_path', 'nonexistent.txt')}`,
|
|
);
|
|
} finally {
|
|
fs.rmSync(baseTmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should throw if realpathSync throws a non-ENOENT error', async () => {
|
|
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
|
|
const error = new Error('Permission denied');
|
|
Object.assign(error, { code: 'EACCES' });
|
|
throw error;
|
|
});
|
|
|
|
const errorManager = new MacOsSandboxManager({
|
|
workspace: mockWorkspace,
|
|
});
|
|
await expect(
|
|
errorManager.prepareCommand({
|
|
command: 'echo',
|
|
args: ['hello'],
|
|
cwd: mockWorkspace,
|
|
env: {},
|
|
}),
|
|
).rejects.toThrow('Permission denied');
|
|
});
|
|
});
|
|
});
|