Files
gemini-cli/packages/core/src/services/sandboxedFileSystemService.test.ts
T
Gal Zahavi 65024d4538 fix(core): ensure global temp directory is always in sandbox allowed paths (#24638)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-04 00:23:27 +00:00

243 lines
6.5 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { SandboxedFileSystemService } from './sandboxedFileSystemService.js';
import type {
SandboxManager,
SandboxRequest,
SandboxedCommand,
GlobalSandboxOptions,
} from './sandboxManager.js';
import { spawn, type ChildProcess } from 'node:child_process';
import { EventEmitter } from 'node:events';
import type { Writable } from 'node:stream';
import path from 'node:path';
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
}));
class MockSandboxManager implements SandboxManager {
prepareCommand = vi.fn(
async (req: SandboxRequest): Promise<SandboxedCommand> => ({
program: 'sandbox.exe',
args: ['0', req.cwd, req.command, ...req.args],
env: req.env || {},
}),
);
isKnownSafeCommand(): boolean {
return false;
}
isDangerousCommand(): boolean {
return false;
}
parseDenials(): undefined {
return undefined;
}
getWorkspace(): string {
return path.resolve('/workspace');
}
getOptions(): GlobalSandboxOptions | undefined {
return {
workspace: path.resolve('/workspace'),
includeDirectories: [path.resolve('/test/cwd')],
};
}
}
describe('SandboxedFileSystemService', () => {
let sandboxManager: MockSandboxManager;
let service: SandboxedFileSystemService;
const cwd = path.resolve('/test/cwd');
beforeEach(() => {
sandboxManager = new MockSandboxManager();
service = new SandboxedFileSystemService(sandboxManager, cwd);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should read a file through the sandbox', async () => {
const mockChild = new EventEmitter() as unknown as ChildProcess;
Object.assign(mockChild, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
vi.mocked(spawn).mockReturnValue(mockChild);
const testFile = path.resolve('/test/cwd/file.txt');
const readPromise = service.readTextFile(testFile);
// Use setImmediate to ensure events are emitted after the promise starts executing
setImmediate(() => {
mockChild.stdout!.emit('data', Buffer.from('file content'));
mockChild.emit('close', 0);
});
const content = await readPromise;
expect(content).toBe('file content');
expect(vi.mocked(sandboxManager.prepareCommand)).toHaveBeenCalledWith(
expect.objectContaining({
command: '__read',
args: [testFile],
policy: {
allowedPaths: [testFile],
},
}),
);
expect(spawn).toHaveBeenCalledWith(
'sandbox.exe',
['0', cwd, '__read', testFile],
expect.any(Object),
);
});
it('should write a file through the sandbox', async () => {
const mockChild = new EventEmitter() as unknown as ChildProcess;
const mockStdin = new EventEmitter();
Object.assign(mockStdin, {
write: vi.fn(),
end: vi.fn(),
});
Object.assign(mockChild, {
stdin: mockStdin as unknown as Writable,
stderr: new EventEmitter(),
});
vi.mocked(spawn).mockReturnValue(mockChild);
const testFile = path.resolve('/test/cwd/file.txt');
const writePromise = service.writeTextFile(testFile, 'new content');
setImmediate(() => {
mockChild.emit('close', 0);
});
await writePromise;
expect(
(mockStdin as unknown as { write: Mock }).write,
).toHaveBeenCalledWith('new content');
expect((mockStdin as unknown as { end: Mock }).end).toHaveBeenCalled();
expect(vi.mocked(sandboxManager.prepareCommand)).toHaveBeenCalledWith(
expect.objectContaining({
command: '__write',
args: [testFile],
policy: {
allowedPaths: [testFile],
additionalPermissions: {
fileSystem: {
write: [testFile],
},
},
},
}),
);
expect(spawn).toHaveBeenCalledWith(
'sandbox.exe',
['0', cwd, '__write', testFile],
expect.any(Object),
);
});
it('should reject if sandbox command fails', async () => {
const mockChild = new EventEmitter() as unknown as ChildProcess;
Object.assign(mockChild, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
vi.mocked(spawn).mockReturnValue(mockChild);
const testFile = path.resolve('/test/cwd/file.txt');
const readPromise = service.readTextFile(testFile);
setImmediate(() => {
mockChild.stderr!.emit('data', Buffer.from('access denied'));
mockChild.emit('close', 1);
});
await expect(readPromise).rejects.toThrow(
`Sandbox Error: read_file failed for '${testFile}'. Exit code 1. Details: access denied`,
);
});
it('should set ENOENT code when file does not exist', async () => {
const mockChild = new EventEmitter() as unknown as ChildProcess;
Object.assign(mockChild, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
vi.mocked(spawn).mockReturnValue(mockChild);
const testFile = path.resolve('/test/cwd/missing.txt');
const readPromise = service.readTextFile(testFile);
setImmediate(() => {
mockChild.stderr!.emit('data', Buffer.from('No such file or directory'));
mockChild.emit('close', 1);
});
try {
await readPromise;
expect.fail('Should have rejected');
} catch (err: unknown) {
// @ts-expect-error - Checking message and code on unknown error
expect(err.message).toContain('No such file or directory');
// @ts-expect-error - Checking message and code on unknown error
expect(err.code).toBe('ENOENT');
}
});
it('should set ENOENT code when file does not exist on Windows', async () => {
const mockChild = new EventEmitter() as unknown as ChildProcess;
Object.assign(mockChild, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
vi.mocked(spawn).mockReturnValue(mockChild);
const testFile = path.resolve('/test/cwd/missing.txt');
const readPromise = service.readTextFile(testFile);
setImmediate(() => {
mockChild.stderr!.emit(
'data',
Buffer.from('Could not find a part of the path'),
);
mockChild.emit('close', 1);
});
try {
await readPromise;
expect.fail('Should have rejected');
} catch (err: unknown) {
const error = err as { message: string; code?: string };
expect(error.message).toContain('Could not find a part of the path');
expect(error.code).toBe('ENOENT');
}
});
});