mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-12 06:10:42 -07:00
181 lines
5.2 KiB
TypeScript
181 lines
5.2 KiB
TypeScript
|
|
/**
|
||
|
|
* @license
|
||
|
|
* Copyright 2026 Google LLC
|
||
|
|
* SPDX-License-Identifier: Apache-2.0
|
||
|
|
*/
|
||
|
|
|
||
|
|
import {
|
||
|
|
vi,
|
||
|
|
describe,
|
||
|
|
it,
|
||
|
|
expect,
|
||
|
|
beforeEach,
|
||
|
|
beforeAll,
|
||
|
|
afterEach,
|
||
|
|
} from 'vitest';
|
||
|
|
import os from 'node:os';
|
||
|
|
import type _fs from 'node:fs';
|
||
|
|
import { ShellTool } from './shell.js';
|
||
|
|
import { type Config } from '../config/config.js';
|
||
|
|
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||
|
|
import * as proactivePermissions from '../sandbox/utils/proactivePermissions.js';
|
||
|
|
|
||
|
|
import { initializeShellParsers } from '../utils/shell-utils.js';
|
||
|
|
|
||
|
|
vi.mock('node:fs', async (importOriginal) => {
|
||
|
|
const original = await importOriginal<typeof import('node:fs')>();
|
||
|
|
return {
|
||
|
|
...original,
|
||
|
|
default: {
|
||
|
|
...original,
|
||
|
|
realpathSync: vi.fn((p) => p),
|
||
|
|
},
|
||
|
|
realpathSync: vi.fn((p) => p),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
vi.mock('../sandbox/utils/proactivePermissions.js', () => ({
|
||
|
|
getProactiveToolSuggestions: vi.fn(),
|
||
|
|
isNetworkReliantCommand: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const mockPlatform = (platform: string) => {
|
||
|
|
vi.stubGlobal(
|
||
|
|
'process',
|
||
|
|
Object.create(process, {
|
||
|
|
platform: {
|
||
|
|
get: () => platform,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
vi.spyOn(os, 'platform').mockReturnValue(platform as NodeJS.Platform);
|
||
|
|
};
|
||
|
|
|
||
|
|
describe('ShellTool Proactive Expansion', () => {
|
||
|
|
let mockConfig: Config;
|
||
|
|
let shellTool: ShellTool;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
await initializeShellParsers();
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
vi.unstubAllGlobals();
|
||
|
|
vi.restoreAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
mockPlatform('darwin');
|
||
|
|
|
||
|
|
mockConfig = {
|
||
|
|
get config() {
|
||
|
|
return this;
|
||
|
|
},
|
||
|
|
getSandboxEnabled: vi.fn().mockReturnValue(false),
|
||
|
|
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||
|
|
getApprovalMode: vi.fn().mockReturnValue('strict'),
|
||
|
|
sandboxPolicyManager: {
|
||
|
|
getCommandPermissions: vi.fn().mockReturnValue({
|
||
|
|
fileSystem: { read: [], write: [] },
|
||
|
|
network: false,
|
||
|
|
}),
|
||
|
|
getModeConfig: vi.fn().mockReturnValue({ readonly: false }),
|
||
|
|
},
|
||
|
|
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||
|
|
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||
|
|
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
|
||
|
|
} as unknown as Config;
|
||
|
|
|
||
|
|
const bus = createMockMessageBus();
|
||
|
|
shellTool = new ShellTool(mockConfig, bus);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should NOT call getProactiveToolSuggestions when sandboxing is disabled', async () => {
|
||
|
|
const invocation = shellTool.build({ command: 'npm install' });
|
||
|
|
const abortSignal = new AbortController().signal;
|
||
|
|
|
||
|
|
await invocation.shouldConfirmExecute(abortSignal);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
proactivePermissions.getProactiveToolSuggestions,
|
||
|
|
).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should call getProactiveToolSuggestions when sandboxing is enabled', async () => {
|
||
|
|
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
|
||
|
|
vi.mocked(
|
||
|
|
proactivePermissions.getProactiveToolSuggestions,
|
||
|
|
).mockResolvedValue({
|
||
|
|
network: true,
|
||
|
|
});
|
||
|
|
vi.mocked(proactivePermissions.isNetworkReliantCommand).mockReturnValue(
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
|
||
|
|
const invocation = shellTool.build({ command: 'npm install' });
|
||
|
|
const abortSignal = new AbortController().signal;
|
||
|
|
|
||
|
|
await invocation.shouldConfirmExecute(abortSignal);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
proactivePermissions.getProactiveToolSuggestions,
|
||
|
|
).toHaveBeenCalledWith('npm');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should normalize command names (lowercase and strip .exe) when sandboxing is enabled', async () => {
|
||
|
|
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
|
||
|
|
vi.mocked(
|
||
|
|
proactivePermissions.getProactiveToolSuggestions,
|
||
|
|
).mockResolvedValue({
|
||
|
|
network: true,
|
||
|
|
});
|
||
|
|
vi.mocked(proactivePermissions.isNetworkReliantCommand).mockReturnValue(
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
|
||
|
|
const invocation = shellTool.build({ command: 'NPM.EXE install' });
|
||
|
|
const abortSignal = new AbortController().signal;
|
||
|
|
|
||
|
|
await invocation.shouldConfirmExecute(abortSignal);
|
||
|
|
|
||
|
|
expect(
|
||
|
|
proactivePermissions.getProactiveToolSuggestions,
|
||
|
|
).toHaveBeenCalledWith('npm');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should NOT request expansion if paths are already approved (case-insensitive subpath)', async () => {
|
||
|
|
// This test assumes Darwin or Windows for case-insensitivity
|
||
|
|
vi.mocked(mockConfig.getSandboxEnabled).mockReturnValue(true);
|
||
|
|
vi.mocked(
|
||
|
|
proactivePermissions.getProactiveToolSuggestions,
|
||
|
|
).mockResolvedValue({
|
||
|
|
fileSystem: { read: ['/project/src'], write: [] },
|
||
|
|
});
|
||
|
|
vi.mocked(proactivePermissions.isNetworkReliantCommand).mockReturnValue(
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
|
||
|
|
// Current approval is for the parent dir, with different casing
|
||
|
|
vi.mocked(
|
||
|
|
mockConfig.sandboxPolicyManager.getCommandPermissions,
|
||
|
|
).mockReturnValue({
|
||
|
|
fileSystem: { read: ['/PROJECT'], write: [] },
|
||
|
|
network: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
const invocation = shellTool.build({ command: 'npm install' });
|
||
|
|
const result = await invocation.shouldConfirmExecute(
|
||
|
|
new AbortController().signal,
|
||
|
|
);
|
||
|
|
|
||
|
|
// If it's correctly approved, result should be false (no expansion needed)
|
||
|
|
// or a normal 'exec' confirmation, but NOT 'sandbox_expansion'.
|
||
|
|
if (result) {
|
||
|
|
expect(result.type).not.toBe('sandbox_expansion');
|
||
|
|
} else {
|
||
|
|
expect(result).toBe(false);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|