Files
gemini-cli/packages/core/src/tools/shell_proactive.test.ts

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);
}
});
});